├── .github ├── ISSUE_TEMPLATE │ ├── bug.yaml │ ├── documentation.yaml │ └── feature.yaml └── workflows │ ├── linter.yml │ └── tests.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── composer.json ├── composer.lock ├── docker-compose.yml ├── docs └── adding-new-storage-adapter.md ├── phpunit.xml ├── psalm.xml ├── src └── Storage │ ├── Device.php │ ├── Device │ ├── Backblaze.php │ ├── DOSpaces.php │ ├── Linode.php │ ├── Local.php │ ├── S3.php │ └── Wasabi.php │ ├── Storage.php │ └── Validator │ ├── File.php │ ├── FileExt.php │ ├── FileName.php │ ├── FileSize.php │ ├── FileType.php │ └── Upload.php └── tests ├── Storage ├── Device │ ├── BackblazeTest.php │ ├── DOSpacesTest.php │ ├── LinodeTest.php │ ├── LocalTest.php │ ├── S3Test.php │ └── WasabiTest.php ├── S3Base.php ├── StorageTest.php └── Validator │ ├── FileExtTest.php │ ├── FileNameTest.php │ ├── FileSizeTest.php │ ├── FileTypeTest.php │ └── UploadTest.php └── resources ├── disk-a ├── config.xml ├── kitten-1.jpg ├── kitten-2.jpg ├── kitten-3.gif ├── large_file.mp4 └── lorem.txt └── disk-b ├── appwrite.svg ├── kitten-1.png └── kitten-2.png /.github/ISSUE_TEMPLATE/bug.yaml: -------------------------------------------------------------------------------- 1 | name: "🐛 Bug Report" 2 | description: "Submit a bug report to help us improve" 3 | title: "🐛 Bug Report: " 4 | labels: [bug] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out our bug report form 🙏 10 | - type: textarea 11 | id: steps-to-reproduce 12 | validations: 13 | required: true 14 | attributes: 15 | label: "👟 Reproduction steps" 16 | description: "How do you trigger this bug? Please walk us through it step by step." 17 | placeholder: "When I ..." 18 | - type: textarea 19 | id: expected-behavior 20 | validations: 21 | required: true 22 | attributes: 23 | label: "👍 Expected behavior" 24 | description: "What did you think would happen?" 25 | placeholder: "It should ..." 26 | - type: textarea 27 | id: actual-behavior 28 | validations: 29 | required: true 30 | attributes: 31 | label: "👎 Actual Behavior" 32 | description: "What did actually happen? Add screenshots, if applicable." 33 | placeholder: "It actually ..." 34 | - type: dropdown 35 | id: appwrite-version 36 | attributes: 37 | label: "🎲 Utopia Storage Version" 38 | description: "What version of Utopia Storage are you running?" 39 | options: 40 | - Version 0.5.x 41 | - Version 0.4.x 42 | - Version 0.3.x 43 | - Version 0.2.x 44 | - Version 0.1.x 45 | validations: 46 | required: true 47 | - type: dropdown 48 | id: operating-system 49 | attributes: 50 | label: "💻 Operating system" 51 | description: "What OS is your server / device running on?" 52 | options: 53 | - Linux 54 | - MacOS 55 | - Windows 56 | - Something else 57 | validations: 58 | required: true 59 | - type: textarea 60 | id: enviromnemt 61 | validations: 62 | required: false 63 | attributes: 64 | label: "🧱 Your Environment" 65 | description: "Is your environment customized in any way?" 66 | placeholder: "I use Cloudflare for ..." 67 | - type: checkboxes 68 | id: no-duplicate-issues 69 | attributes: 70 | label: "👀 Have you spent some time to check if this issue has been raised before?" 71 | description: "Have you Googled for a similar issue or checked our older issues for a similar bug?" 72 | options: 73 | - label: "I checked and didn't find similar issue" 74 | required: true 75 | - type: checkboxes 76 | id: read-code-of-conduct 77 | attributes: 78 | label: "🏢 Have you read the Code of Conduct?" 79 | options: 80 | - label: "I have read the [Code of Conduct](https://github.com/appwrite/appwrite/blob/HEAD/CODE_OF_CONDUCT.md)" 81 | required: true 82 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.yaml: -------------------------------------------------------------------------------- 1 | name: "📚 Documentation" 2 | description: "Report an issue related to documentation" 3 | title: "📚 Documentation: " 4 | labels: [documentation] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out our bug report form 🙏 10 | - type: textarea 11 | id: issue-description 12 | validations: 13 | required: true 14 | attributes: 15 | label: "💭 Description" 16 | description: "A clear and concise description of what the issue is." 17 | placeholder: "Documentation should not ..." 18 | - type: checkboxes 19 | id: no-duplicate-issues 20 | attributes: 21 | label: "👀 Have you spent some time to check if this issue has been raised before?" 22 | description: "Have you Googled for a similar issue or checked our older issues for a similar bug?" 23 | options: 24 | - label: "I checked and didn't find similar issue" 25 | required: true 26 | - type: checkboxes 27 | id: read-code-of-conduct 28 | attributes: 29 | label: "🏢 Have you read the Code of Conduct?" 30 | options: 31 | - label: "I have read the [Code of Conduct](https://github.com/appwrite/appwrite/blob/HEAD/CODE_OF_CONDUCT.md)" 32 | required: true 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.yaml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature 2 | description: "Submit a proposal for a new feature" 3 | title: "🚀 Feature: " 4 | labels: [feature] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out our feature report form 🙏 10 | - type: textarea 11 | id: feature-description 12 | validations: 13 | required: true 14 | attributes: 15 | label: "🔖 Feature description" 16 | description: "A clear and concise description of what the feature is." 17 | placeholder: "You should add ..." 18 | - type: textarea 19 | id: pitch 20 | validations: 21 | required: true 22 | attributes: 23 | label: "🎤 Pitch" 24 | description: "Please explain why this feature should be implemented and how it would be used. Add examples, if applicable." 25 | placeholder: "In my use-case, ..." 26 | - type: checkboxes 27 | id: no-duplicate-issues 28 | attributes: 29 | label: "👀 Have you spent some time to check if this issue has been raised before?" 30 | description: "Have you Googled for a similar issue or checked our older issues for a similar feature suggestion?" 31 | options: 32 | - label: "I checked and didn't find similar issue" 33 | required: true 34 | - type: checkboxes 35 | id: read-code-of-conduct 36 | attributes: 37 | label: "🏢 Have you read the Code of Conduct?" 38 | options: 39 | - label: "I have read the [Code of Conduct](https://github.com/appwrite/appwrite/blob/HEAD/CODE_OF_CONDUCT.md)" 40 | required: true 41 | -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | name: "Linter" 2 | 3 | on: [pull_request] 4 | jobs: 5 | lint: 6 | name: Linter 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout repository 11 | uses: actions/checkout@v3 12 | with: 13 | fetch-depth: 2 14 | 15 | - run: git checkout HEAD^2 16 | 17 | - name: Run Linter 18 | run: | 19 | docker run --rm -v $PWD:/app composer sh -c \ 20 | "composer install --profile --ignore-platform-reqs && composer lint" 21 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: "Tests" 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: [pull_request] 8 | jobs: 9 | build: 10 | name: Build & Unit 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout Repo 15 | uses: actions/checkout@v3 16 | 17 | - name: Set up Docker Buildx 18 | uses: docker/setup-buildx-action@v2 19 | 20 | - name: Build image 21 | uses: docker/build-push-action@v3 22 | with: 23 | context: . 24 | push: false 25 | load: true 26 | tags: storage-dev 27 | cache-from: type=gha 28 | cache-to: type=gha,mode=max 29 | 30 | - name: Start storage 31 | env: 32 | DO_ACCESS_KEY: ${{ secrets.DO_ACCESS_KEY }} 33 | DO_SECRET: ${{ secrets.DO_SECRET }} 34 | LINODE_ACCESS_KEY: ${{ secrets.LINODE_ACCESS_KEY }} 35 | LINODE_SECRET: ${{ secrets.LINODE_SECRET }} 36 | S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} 37 | S3_SECRET: ${{ secrets.S3_SECRET }} 38 | WASABI_ACCESS_KEY: ${{ secrets.WASABI_ACCESS_KEY }} 39 | WASABI_SECRET: ${{ secrets.WASABI_SECRET }} 40 | BACKBLAZE_ACCESS_KEY: ${{ secrets.BACKBLAZE_ACCESS_KEY }} 41 | BACKBLAZE_SECRET: ${{ secrets.BACKBLAZE_SECRET }} 42 | run: | 43 | docker compose up -d 44 | sleep 10 45 | 46 | - name: Doctor 47 | run: | 48 | docker compose logs tests 49 | docker ps 50 | 51 | - name: Unit Tests 52 | run: docker compose exec -T tests vendor/bin/phpunit --configuration phpunit.xml --debug --testsuite unit 53 | 54 | e2e_test: 55 | name: E2E Test 56 | runs-on: ubuntu-latest 57 | needs: build 58 | strategy: 59 | fail-fast: false 60 | matrix: 61 | devices: [BackblazeTest, DOSpacesTest, LinodeTest, LocalTest, S3Test, WasabiTest] 62 | 63 | steps: 64 | - name: checkout 65 | uses: actions/checkout@v3 66 | - name: Start storage 67 | env: 68 | DO_ACCESS_KEY: ${{ secrets.DO_ACCESS_KEY }} 69 | DO_SECRET: ${{ secrets.DO_SECRET }} 70 | LINODE_ACCESS_KEY: ${{ secrets.LINODE_ACCESS_KEY }} 71 | LINODE_SECRET: ${{ secrets.LINODE_SECRET }} 72 | S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} 73 | S3_SECRET: ${{ secrets.S3_SECRET }} 74 | WASABI_ACCESS_KEY: ${{ secrets.WASABI_ACCESS_KEY }} 75 | WASABI_SECRET: ${{ secrets.WASABI_SECRET }} 76 | BACKBLAZE_ACCESS_KEY: ${{ secrets.BACKBLAZE_ACCESS_KEY }} 77 | BACKBLAZE_SECRET: ${{ secrets.BACKBLAZE_SECRET }} 78 | run: | 79 | docker compose up -d 80 | sleep 10 81 | - name: Run ${{matrix.devices}} 82 | run: docker compose exec -T tests vendor/bin/phpunit tests/Storage/Device/${{matrix.devices}}.php 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /.vscode/ 3 | .phpunit.result.cache 4 | tests/chunk.php 5 | .idea/ 6 | .env -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity, expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at team@appwrite.io. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We would ❤️ for you to contribute to Utopia-php and help make it better! We want contributing to Utopia-php to be fun, enjoyable, and educational for anyone and everyone. All contributions are welcome, including issues, new docs as well as updates and tweaks, blog posts, workshops, and more. 4 | 5 | ## How to Start? 6 | 7 | If you are worried or don’t know where to start, check out our next section explaining what kind of help we could use and where can you get involved. You can reach out with questions to [Eldad Fux (@eldadfux)](https://twitter.com/eldadfux) or anyone from the [Appwrite team on Discord](https://discord.gg/GSeTUeA). You can also submit an issue, and a maintainer can guide you! 8 | 9 | ## Code of Conduct 10 | 11 | Help us keep Utopia-php open and inclusive. Please read and follow our [Code of Conduct](/CODE_OF_CONDUCT.md). 12 | 13 | ## Submit a Pull Request 🚀 14 | 15 | Branch naming convention is as following 16 | 17 | `TYPE-ISSUE_ID-DESCRIPTION` 18 | 19 | example: 20 | 21 | ``` 22 | doc-548-submit-a-pull-request-section-to-contribution-guide 23 | ``` 24 | 25 | When `TYPE` can be: 26 | 27 | - **feat** - is a new feature 28 | - **doc** - documentation only changes 29 | - **cicd** - changes related to CI/CD system 30 | - **fix** - a bug fix 31 | - **refactor** - code change that neither fixes a bug nor adds a feature 32 | 33 | **All PRs must include a commit message with the changes description!** 34 | 35 | For the initial start, fork the project and use git clone command to download the repository to your computer. A standard procedure for working on an issue would be to: 36 | 37 | 1. `git pull`, before creating a new branch, pull the changes from upstream. Your master needs to be up to date. 38 | 39 | ``` 40 | $ git pull 41 | ``` 42 | 43 | 2. Create new branch from `master` like: `doc-548-submit-a-pull-request-section-to-contribution-guide`
44 | 45 | ``` 46 | $ git checkout -b [name_of_your_new_branch] 47 | ``` 48 | 49 | 3. Work - commit - repeat ( be sure to be in your branch ) 50 | 51 | 4. Push changes to GitHub 52 | 53 | ``` 54 | $ git push origin [name_of_your_new_branch] 55 | ``` 56 | 57 | 5. Submit your changes for review 58 | If you go to your repository on GitHub, you'll see a `Compare & pull request` button. Click on that button. 59 | 6. Start a Pull Request 60 | Now submit the pull request and click on `Create pull request`. 61 | 7. Get a code review approval/reject 62 | 8. After approval, merge your PR 63 | 9. GitHub will automatically delete the branch after the merge is done. (they can still be restored). 64 | 65 | ## Introducing New Features 66 | 67 | We would 💖 you to contribute to Utopia-php, but we would also like to make sure Utopia-php is as great as possible and loyal to its vision and mission statement 🙏. 68 | 69 | For us to find the right balance, please open an issue explaining your ideas before introducing a new pull request. 70 | 71 | This will allow the Utopia-php community to have sufficient discussion about the new feature value and how it fits in the product roadmap and vision. 72 | 73 | This is also important for the Utopia-php lead developers to be able to give technical input and different emphasis regarding the feature design and architecture. Some bigger features might need to go through our [RFC process](https://github.com/appwrite/rfc). 74 | 75 | ## Adding new Storage Adapter 76 | 77 | You can follow our [Adding new Storage Adapter](docs/adding-new-storage-adapter.md) tutorial to add new storage device support in this storage library. 78 | 79 | ## Other Ways to Help 80 | 81 | Pull requests are great, but there are many other areas where you can help Utopia-php. 82 | 83 | ### Blogging & Speaking 84 | 85 | Blogging, speaking about, or creating tutorials about one of Utopia-php’s many features is great way to contribute and help our project grow. 86 | 87 | ### Presenting at Meetups 88 | 89 | Presenting at meetups and conferences about your Utopia-php projects. Your unique challenges and successes in building things with Utopia-php can provide great speaking material. We’d love to review your talk abstract/CFP, so get in touch with us if you’d like some help! 90 | 91 | ### Sending Feedbacks & Reporting Bugs 92 | 93 | Sending feedback is a great way for us to understand your different use cases of Utopia-php better. If you had any issues, bugs, or want to share about your experience, feel free to do so on our GitHub issues page or at our [Discord channel](https://discord.gg/GSeTUeA). 94 | 95 | ### Submitting New Ideas 96 | 97 | If you think Utopia-php could use a new feature, please open an issue on our GitHub repository, stating as much information as you can think about your new idea and it's implications. We would also use this issue to gather more information, get more feedback from the community, and have a proper discussion about the new feature. 98 | 99 | ### Improving Documentation 100 | 101 | Submitting documentation updates, enhancements, designs, or bug fixes. Spelling or grammar fixes will be very much appreciated. 102 | 103 | ### Helping Someone 104 | 105 | Searching for Utopia-php, GitHub or StackOverflow and helping someone else who needs help. You can also help by teaching others how to contribute to Utopia-php's repo! -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM composer:2.0 as composer 2 | 3 | ARG TESTING=false 4 | ENV TESTING=$TESTING 5 | 6 | WORKDIR /usr/local/src/ 7 | 8 | COPY composer.lock /usr/local/src/ 9 | COPY composer.json /usr/local/src/ 10 | 11 | RUN composer update \ 12 | --ignore-platform-reqs \ 13 | --optimize-autoloader \ 14 | --no-plugins \ 15 | --no-scripts \ 16 | --prefer-dist 17 | 18 | FROM php:8.0-cli-alpine as compile 19 | 20 | ENV PHP_ZSTD_VERSION="master" 21 | ENV PHP_BROTLI_VERSION="7ae4fcd8b81a65d7521c298cae49af386d1ea4e3" 22 | ENV PHP_SNAPPY_VERSION="bfefe4906e0abb1f6cc19005b35f9af5240d9025" 23 | ENV PHP_LZ4_VERSION="2f006c3e4f1fb3a60d2656fc164f9ba26b71e995" 24 | ENV PHP_XZ_VERSION=5.2.7 25 | ENV PHP_EXT_XZ_VERSION=1.1.2 26 | 27 | RUN apk add --no-cache \ 28 | git \ 29 | autoconf \ 30 | make \ 31 | g++ \ 32 | zstd-dev \ 33 | brotli-dev \ 34 | lz4-dev 35 | 36 | ## Zstandard Extension 37 | FROM compile AS zstd 38 | RUN git clone --recursive --depth 1 --branch $PHP_ZSTD_VERSION https://github.com/kjdev/php-ext-zstd.git \ 39 | && cd php-ext-zstd \ 40 | && phpize \ 41 | && ./configure --with-libzstd \ 42 | && make && make install 43 | 44 | ## Brotli Extension 45 | FROM compile as brotli 46 | RUN git clone https://github.com/kjdev/php-ext-brotli.git \ 47 | && cd php-ext-brotli \ 48 | && git reset --hard $PHP_BROTLI_VERSION \ 49 | && phpize \ 50 | && ./configure --with-libbrotli \ 51 | && make && make install 52 | 53 | ## LZ4 Extension 54 | FROM compile AS lz4 55 | RUN git clone --recursive https://github.com/kjdev/php-ext-lz4.git \ 56 | && cd php-ext-lz4 \ 57 | && git reset --hard $PHP_LZ4_VERSION \ 58 | && phpize \ 59 | && ./configure --with-lz4-includedir=/usr \ 60 | && make && make install 61 | 62 | ## Snappy Extension 63 | FROM compile AS snappy 64 | RUN git clone --recursive https://github.com/kjdev/php-ext-snappy.git \ 65 | && cd php-ext-snappy \ 66 | && git reset --hard $PHP_SNAPPY_VERSION \ 67 | && phpize \ 68 | && ./configure \ 69 | && make && make install 70 | 71 | ## Xz Extension 72 | FROM compile as xz 73 | RUN wget https://tukaani.org/xz/xz-${PHP_XZ_VERSION}.tar.xz -O xz.tar.xz \ 74 | && tar -xJf xz.tar.xz \ 75 | && rm xz.tar.xz \ 76 | && ( \ 77 | cd xz-${PHP_XZ_VERSION} \ 78 | && ./configure \ 79 | && make \ 80 | && make install \ 81 | ) \ 82 | && rm -r xz-${PHP_XZ_VERSION} 83 | 84 | RUN git clone https://github.com/codemasher/php-ext-xz.git --branch ${PHP_EXT_XZ_VERSION} \ 85 | && cd php-ext-xz \ 86 | && phpize \ 87 | && ./configure \ 88 | && make && make install 89 | 90 | FROM compile as final 91 | 92 | LABEL maintainer="team@appwrite.io" 93 | 94 | WORKDIR /usr/src/code 95 | 96 | RUN echo extension=zstd.so >> /usr/local/etc/php/conf.d/zstd.ini 97 | RUN echo extension=brotli.so >> /usr/local/etc/php/conf.d/brotli.ini 98 | RUN echo extension=lz4.so >> /usr/local/etc/php/conf.d/lz4.ini 99 | RUN echo extension=snappy.so >> /usr/local/etc/php/conf.d/snappy.ini 100 | RUN echo extension=xz.so >> /usr/local/etc/php/conf.d/xz.ini 101 | 102 | RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \ 103 | && echo "opcache.enable_cli=1" >> $PHP_INI_DIR/php.ini \ 104 | && echo "memory_limit=1024M" >> $PHP_INI_DIR/php.ini 105 | 106 | COPY --from=composer /usr/local/src/vendor /usr/src/code/vendor 107 | COPY --from=zstd /usr/local/lib/php/extensions/no-debug-non-zts-20200930/zstd.so /usr/local/lib/php/extensions/no-debug-non-zts-20200930/ 108 | COPY --from=brotli /usr/local/lib/php/extensions/no-debug-non-zts-20200930/brotli.so /usr/local/lib/php/extensions/no-debug-non-zts-20200930/ 109 | COPY --from=lz4 /usr/local/lib/php/extensions/no-debug-non-zts-20200930/lz4.so /usr/local/lib/php/extensions/no-debug-non-zts-20200930/ 110 | COPY --from=snappy /usr/local/lib/php/extensions/no-debug-non-zts-20200930/snappy.so /usr/local/lib/php/extensions/no-debug-non-zts-20200930/ 111 | COPY --from=xz /usr/local/lib/php/extensions/no-debug-non-zts-20200930/xz.so /usr/local/lib/php/extensions/no-debug-non-zts-20200930/ 112 | 113 | # Add Source Code 114 | COPY . /usr/src/code 115 | 116 | CMD [ "tail", "-f", "/dev/null" ] 117 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 utopia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Utopia Storage 2 | 3 | [![Build Status](https://travis-ci.org/utopia-php/storage.svg?branch=master)](https://travis-ci.com/utopia-php/storage) 4 | ![Total Downloads](https://img.shields.io/packagist/dt/utopia-php/storage.svg) 5 | [![Discord](https://img.shields.io/discord/564160730845151244?label=discord)](https://appwrite.io/discord) 6 | 7 | Utopia Storage is a simple and lightweight library for managing application storage across multiple adapters. This library is designed to be easy to learn and use, with a consistent API regardless of the storage provider. This library is maintained by the [Appwrite team](https://appwrite.io). 8 | 9 | This library is part of the [Utopia Framework](https://github.com/utopia-php/framework) project. 10 | 11 | ## Getting Started 12 | 13 | Install using composer: 14 | ```bash 15 | composer require utopia-php/storage 16 | ``` 17 | 18 | ### Basic Usage 19 | 20 | ```php 21 | upload('/local/path/to/file.png', 'destination/path/file.png'); 41 | 42 | // Check if file exists 43 | $exists = $device->exists('destination/path/file.png'); 44 | 45 | // Read file contents 46 | $contents = $device->read('destination/path/file.png'); 47 | 48 | // Delete a file 49 | $device->delete('destination/path/file.png'); 50 | ``` 51 | 52 | ## Available Adapters 53 | 54 | ### Local Storage 55 | 56 | Use the local filesystem for storing files. 57 | 58 | ```php 59 | use Utopia\Storage\Storage; 60 | use Utopia\Storage\Device\Local; 61 | 62 | // Initialize local storage 63 | Storage::setDevice('files', new Local('/path/to/storage')); 64 | ``` 65 | 66 | ### AWS S3 67 | 68 | Store files in Amazon S3 or compatible services. 69 | 70 | ```php 71 | use Utopia\Storage\Storage; 72 | use Utopia\Storage\Device\S3; 73 | 74 | // Initialize S3 storage 75 | Storage::setDevice('files', new S3( 76 | 'root', // Root path in bucket 77 | 'YOUR_ACCESS_KEY', 78 | 'YOUR_SECRET_KEY', 79 | 'YOUR_BUCKET_NAME', 80 | S3::US_EAST_1, // Region (default: us-east-1) 81 | S3::ACL_PRIVATE // Access control (default: private) 82 | )); 83 | 84 | // Available regions 85 | // S3::US_EAST_1, S3::US_EAST_2, S3::US_WEST_1, S3::US_WEST_2, S3::AP_SOUTH_1, 86 | // S3::AP_NORTHEAST_1, S3::AP_NORTHEAST_2, S3::AP_NORTHEAST_3, S3::AP_SOUTHEAST_1, 87 | // S3::AP_SOUTHEAST_2, S3::EU_CENTRAL_1, S3::EU_WEST_1, S3::EU_WEST_2, S3::EU_WEST_3, 88 | // And more - check the S3 class for all available regions 89 | 90 | // Available ACL options 91 | // S3::ACL_PRIVATE, S3::ACL_PUBLIC_READ, S3::ACL_PUBLIC_READ_WRITE, S3::ACL_AUTHENTICATED_READ 92 | ``` 93 | 94 | ### DigitalOcean Spaces 95 | 96 | Store files in DigitalOcean Spaces. 97 | 98 | ```php 99 | use Utopia\Storage\Storage; 100 | use Utopia\Storage\Device\DOSpaces; 101 | 102 | // Initialize DO Spaces storage 103 | Storage::setDevice('files', new DOSpaces( 104 | 'root', // Root path in bucket 105 | 'YOUR_ACCESS_KEY', 106 | 'YOUR_SECRET_KEY', 107 | 'YOUR_BUCKET_NAME', 108 | DOSpaces::NYC3, // Region (default: nyc3) 109 | DOSpaces::ACL_PRIVATE // Access control (default: private) 110 | )); 111 | 112 | // Available regions 113 | // DOSpaces::NYC3, DOSpaces::SGP1, DOSpaces::FRA1, DOSpaces::SFO2, DOSpaces::SFO3, DOSpaces::AMS3 114 | ``` 115 | 116 | ### Backblaze B2 117 | 118 | Store files in Backblaze B2 Cloud Storage. 119 | 120 | ```php 121 | use Utopia\Storage\Storage; 122 | use Utopia\Storage\Device\Backblaze; 123 | 124 | // Initialize Backblaze storage 125 | Storage::setDevice('files', new Backblaze( 126 | 'root', // Root path in bucket 127 | 'YOUR_ACCESS_KEY', 128 | 'YOUR_SECRET_KEY', 129 | 'YOUR_BUCKET_NAME', 130 | Backblaze::US_WEST_004, // Region (default: us-west-004) 131 | Backblaze::ACL_PRIVATE // Access control (default: private) 132 | )); 133 | 134 | // Available regions (clusters) 135 | // Backblaze::US_WEST_000, Backblaze::US_WEST_001, Backblaze::US_WEST_002, 136 | // Backblaze::US_WEST_004, Backblaze::EU_CENTRAL_003 137 | ``` 138 | 139 | ### Linode Object Storage 140 | 141 | Store files in Linode Object Storage. 142 | 143 | ```php 144 | use Utopia\Storage\Storage; 145 | use Utopia\Storage\Device\Linode; 146 | 147 | // Initialize Linode storage 148 | Storage::setDevice('files', new Linode( 149 | 'root', // Root path in bucket 150 | 'YOUR_ACCESS_KEY', 151 | 'YOUR_SECRET_KEY', 152 | 'YOUR_BUCKET_NAME', 153 | Linode::EU_CENTRAL_1, // Region (default: eu-central-1) 154 | Linode::ACL_PRIVATE // Access control (default: private) 155 | )); 156 | 157 | // Available regions 158 | // Linode::EU_CENTRAL_1, Linode::US_SOUTHEAST_1, Linode::US_EAST_1, Linode::AP_SOUTH_1 159 | ``` 160 | 161 | ### Wasabi Cloud Storage 162 | 163 | Store files in Wasabi Cloud Storage. 164 | 165 | ```php 166 | use Utopia\Storage\Storage; 167 | use Utopia\Storage\Device\Wasabi; 168 | 169 | // Initialize Wasabi storage 170 | Storage::setDevice('files', new Wasabi( 171 | 'root', // Root path in bucket 172 | 'YOUR_ACCESS_KEY', 173 | 'YOUR_SECRET_KEY', 174 | 'YOUR_BUCKET_NAME', 175 | Wasabi::EU_CENTRAL_1, // Region (default: eu-central-1) 176 | Wasabi::ACL_PRIVATE // Access control (default: private) 177 | )); 178 | 179 | // Available regions 180 | // Wasabi::US_EAST_1, Wasabi::US_EAST_2, Wasabi::US_WEST_1, Wasabi::US_CENTRAL_1, 181 | // Wasabi::EU_CENTRAL_1, Wasabi::EU_CENTRAL_2, Wasabi::EU_WEST_1, Wasabi::EU_WEST_2, 182 | // Wasabi::AP_NORTHEAST_1, Wasabi::AP_NORTHEAST_2 183 | ``` 184 | 185 | ## Common Operations 186 | 187 | All storage adapters provide a consistent API for working with files: 188 | 189 | ```php 190 | // Get storage device 191 | $device = Storage::getDevice('files'); 192 | 193 | // Upload a file 194 | $device->upload('/path/to/local/file.jpg', 'remote/path/file.jpg'); 195 | 196 | // Check if file exists 197 | $exists = $device->exists('remote/path/file.jpg'); 198 | 199 | // Get file size 200 | $size = $device->getFileSize('remote/path/file.jpg'); 201 | 202 | // Get file MIME type 203 | $mime = $device->getFileMimeType('remote/path/file.jpg'); 204 | 205 | // Get file MD5 hash 206 | $hash = $device->getFileHash('remote/path/file.jpg'); 207 | 208 | // Read file contents 209 | $contents = $device->read('remote/path/file.jpg'); 210 | 211 | // Read partial file contents 212 | $chunk = $device->read('remote/path/file.jpg', 0, 1024); // Read first 1KB 213 | 214 | // Multipart/chunked uploads 215 | $device->upload('/local/file.mp4', 'remote/video.mp4', 1, 3); // Part 1 of 3 216 | 217 | // Create directory 218 | $device->createDirectory('remote/new-directory'); 219 | 220 | // List files in directory 221 | $files = $device->listFiles('remote/directory'); 222 | 223 | // Delete file 224 | $device->delete('remote/path/file.jpg'); 225 | 226 | // Delete directory 227 | $device->deleteDirectory('remote/directory'); 228 | 229 | // Transfer files between storage devices 230 | $sourceDevice = Storage::getDevice('source'); 231 | $targetDevice = Storage::getDevice('target'); 232 | 233 | $sourceDevice->transfer('source/path.jpg', 'target/path.jpg', $targetDevice); 234 | ``` 235 | 236 | ## Adding New Adapters 237 | 238 | For information on adding new storage adapters, see the [Adding New Storage Adapter](https://github.com/utopia-php/storage/blob/master/docs/adding-new-storage-adapter.md) guide. 239 | 240 | ## System Requirements 241 | 242 | Utopia Storage requires PHP 7.4 or later. We recommend using the latest PHP version whenever possible. 243 | 244 | ## Contributing 245 | 246 | For security issues, please email [security@appwrite.io](mailto:security@appwrite.io) instead of posting a public issue in GitHub. 247 | 248 | All code contributions - including those of people having commit access - must go through a pull request and be approved by a core developer before being merged. This is to ensure a proper review of all the code. 249 | 250 | We welcome you to contribute to the Utopia Storage library. For details on how to do this, please refer to our [Contributing Guide](https://github.com/utopia-php/storage/blob/master/CONTRIBUTING.md). 251 | 252 | ## License 253 | 254 | This library is available under the MIT License. 255 | 256 | ## Copyright 257 | 258 | ``` 259 | Copyright (c) 2019-2025 Appwrite Team 260 | ``` 261 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "utopia-php/storage", 3 | "description": "A simple Storage library to manage application storage", 4 | "type": "library", 5 | "keywords": ["php","framework","upf","utopia","storage"], 6 | "license": "MIT", 7 | "authors": [], 8 | "autoload": { 9 | "psr-4": {"Utopia\\Storage\\":"src/Storage"} 10 | }, 11 | "autoload-dev": { 12 | "psr-4": {"Utopia\\Tests\\Storage\\":"tests/Storage"} 13 | }, 14 | "scripts": { 15 | "lint": "./vendor/bin/pint --test", 16 | "format": "./vendor/bin/pint" 17 | }, 18 | "require": { 19 | "ext-fileinfo": "*", 20 | "ext-zlib": "*", 21 | "ext-zstd": "*", 22 | "ext-xz": "*", 23 | "ext-brotli": "*", 24 | "ext-lz4": "*", 25 | "ext-snappy": "*", 26 | "php": ">=8.0", 27 | "utopia-php/framework": "1.0.*", 28 | "utopia-php/system": "0.9.*" 29 | }, 30 | "require-dev": { 31 | "phpunit/phpunit": "^9.3", 32 | "vimeo/psalm": "4.0.1", 33 | "laravel/pint": "1.2.*" 34 | }, 35 | "minimum-stability": "stable" 36 | } 37 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | tests: 5 | container_name: tests 6 | image: storage-dev 7 | build: 8 | context: . 9 | volumes: 10 | - ./src:/usr/src/code/src 11 | - ./tests:/usr/src/code/tests 12 | - ./phpunit.xml:/usr/src/code/phpunit.xml 13 | environment: 14 | - S3_ACCESS_KEY 15 | - S3_SECRET 16 | - DO_ACCESS_KEY 17 | - DO_SECRET 18 | - LINODE_ACCESS_KEY 19 | - LINODE_SECRET 20 | - BACKBLAZE_ACCESS_KEY 21 | - BACKBLAZE_SECRET 22 | - WASABI_ACCESS_KEY 23 | - WASABI_SECRET -------------------------------------------------------------------------------- /docs/adding-new-storage-adapter.md: -------------------------------------------------------------------------------- 1 | # Adding New Storage Adapter 2 | 3 | This document is a part of Utopia PHP contributors' guide. Before you continue reading this document make sure you have read the [Code of Conduct](../CODE_OF_CONDUCT.md) and the [Contributing Guide](../CONTRIBUTING.md). 4 | 5 | ## Getting Started 6 | 7 | Storage adapters help us use various storage services to store our data. As of writing this guide, we already support Local storage, [AWS S3](https://aws.amazon.com/s3/) storage and [Digitalocean Spaces](https://www.digitalocean.com/products/spaces/) storage. 8 | 9 | ## 1. Prerequisities 10 | 11 | It's really easy to contribute to an open source project, but when using GitHub, there are a few steps we need to follow. This section will take you step-by-step through the process of preparing your own local version of `utopia-php/storage`, where you can make any changes without affecting the upstream repository right away. 12 | 13 | > If you are experienced with GitHub or have made a pull request before, you can skip to [Implement new Storage Adapter](#2-implement-new-storage-adapter). 14 | 15 | ### 1.1 Fork the repository 16 | 17 | Before making any changes, you will need to fork the `utopia-php/storage` repository to keep branches on the upstream repo clean. To do that, visit the [repository](https://github.com/utopia-php/storage) and click on the fork button. 18 | 19 | This will redirect you from `github.com/utopia-php/storage` to `github.com/YOUR_USERNAME/storage`, meaning all further changes will reflect on your copy of the repository. Once you are there, click the highlighted `Code` button, copy the URL and clone the repository to your computer using `git clone` command: 20 | 21 | ```shell 22 | $ git clone COPIED_URL 23 | ``` 24 | 25 | > To fork a repository, you will need the git-cli binaries installed and a basic understanding of CLI. If you are a beginner, we recommend you to use `Github Desktop`. It is a really clean and simple visual Git client. 26 | 27 | Finally, you will need to create a `feat-XXX-YYY-storage-adapter` branch based on the `master` branch and switch to it. The `XXX` should represent the issue ID and `YYY` the Storage adapter name. 28 | 29 | ## 2. Implement new Storage Adapter 30 | 31 | ### 2.1 Add new adapter and implement it 32 | 33 | In order to start implementing new storage adapter, add new file inside `src/Storage/Device/YYY.php` where `YYY` is the name of the storage provider in `PascalCase`. Inside the file you should create a class that extends the basic `Device` abstract class. Note that the class name should start with a capital letter, as PHP FIG standards suggest. 34 | 35 | Always use properly named environment variables if any credentials are required. 36 | 37 | ### 2.2. Introduce new device constant 38 | Introduce newly added device constant in `src/Storage/Storage.php` alongside existing device constants. The device constant should start with `const DEVICE_` as the existing ones. 39 | 40 | ## 3. Test your adapter 41 | 42 | After you finish adding your new adapter, you need to ensure that it is usable. Use your newly created adapter to make some sample requests to your storage service. 43 | 44 | Great! You're almost there. You can now move onto writing some tests for your Adapter! 45 | 46 | ### 3.1. Introduce new device tests 47 | Add tests for the newly added device adapter inside `tests/Storage/Device`. Use the existing adapter tests as a reference. The test file and class should be properly named `Test.php` and class should be `Test` 48 | 49 | ### 3.2. Run and verify tests 50 | Run tests using `vendor/bin/phpunit --configuration phpunit.xml` and verify that everything is working correctly. 51 | 52 | If everything goes well, raise a pull request and be ready to respond to any feedback which can arise during our code review. 53 | 54 | ## 4. Raise a pull request 55 | 56 | First of all, commit the changes with the message `Added YYY Storage adapter` and push it. This will publish a new branch to your forked version of `utopia-php/storage`. If you visit it at `github.com/YOUR_USERNAME/storage`, you will see a new alert saying you are ready to submit a pull request. Follow the steps GitHub provides, and at the end, you will have your pull request submitted. 57 | 58 | ## 🤕 Stuck ? 59 | If you need any help with the contribution, feel free to head over to [our discord channel](https://appwrite.io/discord) and we'll be happy to help you out. 60 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | ./tests/Storage/Device/LocalTest.php 13 | ./tests/Storage/Validator 14 | ./tests/Storage/StorageTest.php 15 | 16 | 17 | ./tests/Storage/Device 18 | 19 | 20 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/Storage/Device.php: -------------------------------------------------------------------------------- 1 | transferChunkSize = $chunkSize; 28 | } 29 | 30 | /** 31 | * Get Transfer Chunk Size 32 | * 33 | * @return int 34 | */ 35 | public function getTransferChunkSize(): int 36 | { 37 | return $this->transferChunkSize; 38 | } 39 | 40 | /** 41 | * Get Name. 42 | * 43 | * Get storage device name 44 | * 45 | * @return string 46 | */ 47 | abstract public function getName(): string; 48 | 49 | /** 50 | * Get Type. 51 | * 52 | * Get storage device type 53 | * 54 | * @return string 55 | */ 56 | abstract public function getType(): string; 57 | 58 | /** 59 | * Get Description. 60 | * 61 | * Get storage device description and purpose. 62 | * 63 | * @return string 64 | */ 65 | abstract public function getDescription(): string; 66 | 67 | /** 68 | * Get Root. 69 | * 70 | * Get storage device root path 71 | * 72 | * @return string 73 | */ 74 | abstract public function getRoot(): string; 75 | 76 | /** 77 | * Get Path. 78 | * 79 | * Each device hold a complex directory structure that is being build in this method. 80 | * 81 | * @param string $filename 82 | * @param string $prefix 83 | * @return string 84 | */ 85 | abstract public function getPath(string $filename, string $prefix = null): string; 86 | 87 | /** 88 | * Upload. 89 | * 90 | * Upload a file to desired destination in the selected disk 91 | * return number of chunks uploaded or 0 if it fails. 92 | * 93 | * @param string $source 94 | * @param string $path 95 | * @param int $chunk 96 | * @param int $chunks 97 | * @param array $metadata 98 | * @return int 99 | * 100 | * @throws Exception 101 | */ 102 | abstract public function upload(string $source, string $path, int $chunk = 1, int $chunks = 1, array &$metadata = []): int; 103 | 104 | /** 105 | * Upload Data. 106 | * 107 | * Upload file contents to desired destination in the selected disk. 108 | * return number of chunks uploaded or 0 if it fails. 109 | * 110 | * @param string $data 111 | * @param string $path 112 | * @param string $contentType 113 | * @param int chunk 114 | * @param int chunks 115 | * @param array $metadata 116 | * @return int 117 | * 118 | * @throws Exception 119 | */ 120 | abstract public function uploadData(string $data, string $path, string $contentType, int $chunk = 1, int $chunks = 1, array &$metadata = []): int; 121 | 122 | /** 123 | * Abort Chunked Upload 124 | * 125 | * @param string $path 126 | * @param string $extra 127 | * @return bool 128 | */ 129 | abstract public function abort(string $path, string $extra = ''): bool; 130 | 131 | /** 132 | * Read file by given path. 133 | * 134 | * @param string $path 135 | * @param int $offset 136 | * @param int $length 137 | * @return string 138 | */ 139 | abstract public function read(string $path, int $offset = 0, int $length = null): string; 140 | 141 | /** 142 | * Transfer 143 | * Transfer a file from current device to destination device. 144 | * 145 | * @param string $path 146 | * @param string $destination 147 | * @param Device $device 148 | * @return bool 149 | */ 150 | abstract public function transfer(string $path, string $destination, Device $device): bool; 151 | 152 | /** 153 | * Write file by given path. 154 | * 155 | * @param string $path 156 | * @param string $data 157 | * @return bool 158 | */ 159 | abstract public function write(string $path, string $data, string $contentType): bool; 160 | 161 | /** 162 | * Move file from given source to given path, return true on success and false on failure. 163 | * 164 | * @see http://php.net/manual/en/function.filesize.php 165 | * 166 | * @param string $source 167 | * @param string $target 168 | * @return bool 169 | */ 170 | public function move(string $source, string $target): bool 171 | { 172 | if ($source === $target) { 173 | return false; 174 | } 175 | 176 | if ($this->transfer($source, $target, $this)) { 177 | return $this->delete($source); 178 | } 179 | 180 | return false; 181 | } 182 | 183 | /** 184 | * Delete file in given path return true on success and false on failure. 185 | * 186 | * @see http://php.net/manual/en/function.filesize.php 187 | * 188 | * @param string $path 189 | * @param bool $recursive 190 | * @return bool 191 | */ 192 | abstract public function delete(string $path, bool $recursive = false): bool; 193 | 194 | /** 195 | * Delete files in given path, path must be a directory. return true on success and false on failure. 196 | * 197 | * 198 | * @param string $path 199 | * @return bool 200 | */ 201 | abstract public function deletePath(string $path): bool; 202 | 203 | /** 204 | * Check if file exists 205 | * 206 | * @param string $path 207 | * @return bool 208 | */ 209 | abstract public function exists(string $path): bool; 210 | 211 | /** 212 | * Returns given file path its size. 213 | * 214 | * @see http://php.net/manual/en/function.filesize.php 215 | * 216 | * @param $path 217 | * @return int 218 | */ 219 | abstract public function getFileSize(string $path): int; 220 | 221 | /** 222 | * Returns given file path its mime type. 223 | * 224 | * @see http://php.net/manual/en/function.mime-content-type.php 225 | * 226 | * @param $path 227 | * @return string 228 | */ 229 | abstract public function getFileMimeType(string $path): string; 230 | 231 | /** 232 | * Returns given file path its MD5 hash value. 233 | * 234 | * @see http://php.net/manual/en/function.md5-file.php 235 | * 236 | * @param $path 237 | * @return string 238 | */ 239 | abstract public function getFileHash(string $path): string; 240 | 241 | /** 242 | * Create a directory at the specified path. 243 | * 244 | * Returns true on success or if the directory already exists and false on error 245 | * 246 | * @param $path 247 | * @return bool 248 | */ 249 | abstract public function createDirectory(string $path): bool; 250 | 251 | /** 252 | * Get directory size in bytes. 253 | * 254 | * Return -1 on error 255 | * 256 | * Based on http://www.jonasjohn.de/snippets/php/dir-size.htm 257 | * 258 | * @param $path 259 | * @return int 260 | */ 261 | abstract public function getDirectorySize(string $path): int; 262 | 263 | /** 264 | * Get Partition Free Space. 265 | * 266 | * disk_free_space — Returns available space on filesystem or disk partition 267 | * 268 | * @return float 269 | */ 270 | abstract public function getPartitionFreeSpace(): float; 271 | 272 | /** 273 | * Get Partition Total Space. 274 | * 275 | * disk_total_space — Returns the total size of a filesystem or disk partition 276 | * 277 | * @return float 278 | */ 279 | abstract public function getPartitionTotalSpace(): float; 280 | 281 | /** 282 | * Get all files and directories inside a directory. 283 | * 284 | * @param string $dir Directory to scan 285 | * @param int $max 286 | * @param string $continuationToken 287 | * @return array 288 | */ 289 | abstract public function getFiles(string $dir, int $max = self::MAX_PAGE_SIZE, string $continuationToken = ''): array; 290 | 291 | /** 292 | * Get the absolute path by resolving strings like ../, .., //, /\ and so on. 293 | * 294 | * Works like the realpath function but works on files that does not exist 295 | * 296 | * Reference https://www.php.net/manual/en/function.realpath.php#84012 297 | * 298 | * @param string $path 299 | * @return string 300 | */ 301 | public function getAbsolutePath(string $path): string 302 | { 303 | $path = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path); 304 | $parts = array_filter(explode(DIRECTORY_SEPARATOR, $path), 'strlen'); 305 | 306 | $absolutes = []; 307 | foreach ($parts as $part) { 308 | if ('.' == $part) { 309 | continue; 310 | } 311 | if ('..' == $part) { 312 | array_pop($absolutes); 313 | } else { 314 | $absolutes[] = $part; 315 | } 316 | } 317 | 318 | return DIRECTORY_SEPARATOR.implode(DIRECTORY_SEPARATOR, $absolutes); 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /src/Storage/Device/Backblaze.php: -------------------------------------------------------------------------------- 1 | headers['host'] = $bucket.'.'.'s3'.'.'.$region.'.backblazeb2.com'; 39 | } 40 | 41 | /** 42 | * @return string 43 | */ 44 | public function getName(): string 45 | { 46 | return 'Backblaze B2 Storage'; 47 | } 48 | 49 | /** 50 | * @return string 51 | */ 52 | public function getDescription(): string 53 | { 54 | return 'Backblaze B2 Storage'; 55 | } 56 | 57 | /** 58 | * @return string 59 | */ 60 | public function getType(): string 61 | { 62 | return Storage::DEVICE_BACKBLAZE; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Storage/Device/DOSpaces.php: -------------------------------------------------------------------------------- 1 | headers['host'] = $bucket.'.'.$region.'.digitaloceanspaces.com'; 38 | } 39 | 40 | /** 41 | * @return string 42 | */ 43 | public function getName(): string 44 | { 45 | return 'Digitalocean Spaces Storage'; 46 | } 47 | 48 | /** 49 | * @return string 50 | */ 51 | public function getDescription(): string 52 | { 53 | return 'Digitalocean Spaces Storage'; 54 | } 55 | 56 | /** 57 | * @return string 58 | */ 59 | public function getType(): string 60 | { 61 | return Storage::DEVICE_DO_SPACES; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Storage/Device/Linode.php: -------------------------------------------------------------------------------- 1 | headers['host'] = $bucket.'.'.$region.'.'.'linodeobjects.com'; 34 | } 35 | 36 | /** 37 | * @return string 38 | */ 39 | public function getName(): string 40 | { 41 | return 'Linode Object Storage'; 42 | } 43 | 44 | /** 45 | * @return string 46 | */ 47 | public function getDescription(): string 48 | { 49 | return 'Linode Object Storage'; 50 | } 51 | 52 | /** 53 | * @return string 54 | */ 55 | public function getType(): string 56 | { 57 | return Storage::DEVICE_LINODE; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Storage/Device/Local.php: -------------------------------------------------------------------------------- 1 | root = $root; 24 | } 25 | 26 | /** 27 | * @return string 28 | */ 29 | public function getName(): string 30 | { 31 | return 'Local Storage'; 32 | } 33 | 34 | /** 35 | * @return string 36 | */ 37 | public function getType(): string 38 | { 39 | return Storage::DEVICE_LOCAL; 40 | } 41 | 42 | /** 43 | * @return string 44 | */ 45 | public function getDescription(): string 46 | { 47 | return 'Adapter for Local storage that is in the physical or virtual machine or mounted to it.'; 48 | } 49 | 50 | /** 51 | * @return string 52 | */ 53 | public function getRoot(): string 54 | { 55 | return $this->root; 56 | } 57 | 58 | /** 59 | * @param string $filename 60 | * @param string|null $prefix 61 | * @return string 62 | */ 63 | public function getPath(string $filename, string $prefix = null): string 64 | { 65 | return $this->getAbsolutePath($this->getRoot().DIRECTORY_SEPARATOR.$filename); 66 | } 67 | 68 | /** 69 | * Upload. 70 | * 71 | * Upload a file to desired destination in the selected disk. 72 | * return number of chunks uploaded or 0 if it fails. 73 | * 74 | * @param string $source 75 | * @param string $path 76 | * @param int $chunk 77 | * @param int $chunks 78 | * @param array $metadata 79 | * @return int 80 | * 81 | * @throws \Exception 82 | */ 83 | public function upload(string $source, string $path, int $chunk = 1, int $chunks = 1, array &$metadata = []): int 84 | { 85 | $this->createDirectory(\dirname($path)); 86 | 87 | //move_uploaded_file() verifies the file is not tampered with 88 | if ($chunks === 1) { 89 | if (! \move_uploaded_file($source, $path)) { 90 | throw new Exception('Can\'t upload file '.$path); 91 | } 92 | 93 | return $chunks; 94 | } 95 | $tmp = \dirname($path).DIRECTORY_SEPARATOR.'tmp_'.\basename($path).DIRECTORY_SEPARATOR.\basename($path).'_chunks.log'; 96 | 97 | $this->createDirectory(\dirname($tmp)); 98 | 99 | $chunkFilePath = dirname($tmp).DIRECTORY_SEPARATOR.pathinfo($path, PATHINFO_FILENAME).'.part.'.$chunk; 100 | 101 | // skip writing chunk if the chunk was re-uploaded 102 | if (! file_exists($chunkFilePath)) { 103 | if (! file_put_contents($tmp, "$chunk\n", FILE_APPEND)) { 104 | throw new Exception('Can\'t write chunk log '.$tmp); 105 | } 106 | } 107 | 108 | $chunkLogs = file($tmp); 109 | if (! $chunkLogs) { 110 | throw new Exception('Unable to read chunk log '.$tmp); 111 | } 112 | 113 | $chunksReceived = count(file($tmp)); 114 | 115 | if (! \rename($source, $chunkFilePath)) { 116 | throw new Exception('Failed to write chunk '.$chunk); 117 | } 118 | 119 | if ($chunks === $chunksReceived) { 120 | $this->joinChunks($path, $chunks); 121 | 122 | return $chunksReceived; 123 | } 124 | 125 | return $chunksReceived; 126 | } 127 | 128 | /** 129 | * Upload Data. 130 | * 131 | * Upload file contents to desired destination in the selected disk. 132 | * return number of chunks uploaded or 0 if it fails. 133 | * 134 | * @param string $source 135 | * @param string $path 136 | * @param string $contentType 137 | * @param int chunk 138 | * @param int chunks 139 | * @param array $metadata 140 | * @return int 141 | * 142 | * @throws \Exception 143 | */ 144 | public function uploadData(string $data, string $path, string $contentType, int $chunk = 1, int $chunks = 1, array &$metadata = []): int 145 | { 146 | $this->createDirectory(\dirname($path)); 147 | 148 | if ($chunks === 1) { 149 | if (! \file_put_contents($path, $data)) { 150 | throw new Exception('Can\'t write file '.$path); 151 | } 152 | 153 | return $chunks; 154 | } 155 | $tmp = \dirname($path).DIRECTORY_SEPARATOR.'tmp_'.\basename($path).DIRECTORY_SEPARATOR.\basename($path).'_chunks.log'; 156 | 157 | $this->createDirectory(\dirname($tmp)); 158 | if (! file_put_contents($tmp, "$chunk\n", FILE_APPEND)) { 159 | throw new Exception('Can\'t write chunk log '.$tmp); 160 | } 161 | 162 | $chunkLogs = file($tmp); 163 | if (! $chunkLogs) { 164 | throw new Exception('Unable to read chunk log '.$tmp); 165 | } 166 | 167 | $chunksReceived = count(file($tmp)); 168 | 169 | if (! \file_put_contents(dirname($tmp).DIRECTORY_SEPARATOR.pathinfo($path, PATHINFO_FILENAME).'.part.'.$chunk, $data)) { 170 | throw new Exception('Failed to write chunk '.$chunk); 171 | } 172 | 173 | if ($chunks === $chunksReceived) { 174 | $this->joinChunks($path, $chunks); 175 | 176 | return $chunksReceived; 177 | } 178 | 179 | return $chunksReceived; 180 | } 181 | 182 | private function joinChunks(string $path, int $chunks) 183 | { 184 | $tmp = \dirname($path).DIRECTORY_SEPARATOR.'tmp_'.\basename($path).DIRECTORY_SEPARATOR.\basename($path).'_chunks.log'; 185 | for ($i = 1; $i <= $chunks; $i++) { 186 | $part = dirname($tmp).DIRECTORY_SEPARATOR.pathinfo($path, PATHINFO_FILENAME).'.part.'.$i; 187 | $data = file_get_contents($part); 188 | if (! $data) { 189 | throw new Exception('Failed to read chunk '.$part); 190 | } 191 | 192 | if (! file_put_contents($path, $data, FILE_APPEND)) { 193 | throw new Exception('Failed to append chunk '.$part); 194 | } 195 | \unlink($part); 196 | } 197 | \unlink($tmp); 198 | \rmdir(dirname($tmp)); 199 | } 200 | 201 | /** 202 | * Transfer 203 | * 204 | * @param string $path 205 | * @param string $destination 206 | * @param Device $device 207 | * @return string 208 | */ 209 | public function transfer(string $path, string $destination, Device $device): bool 210 | { 211 | if (! $this->exists($path)) { 212 | throw new Exception('File Not Found'); 213 | } 214 | $size = $this->getFileSize($path); 215 | $contentType = $this->getFileMimeType($path); 216 | 217 | if ($size <= $this->transferChunkSize) { 218 | $source = $this->read($path); 219 | 220 | return $device->write($destination, $source, $contentType); 221 | } 222 | 223 | $totalChunks = \ceil($size / $this->transferChunkSize); 224 | $metadata = ['content_type' => $contentType]; 225 | for ($counter = 0; $counter < $totalChunks; $counter++) { 226 | $start = $counter * $this->transferChunkSize; 227 | $data = $this->read($path, $start, $this->transferChunkSize); 228 | $device->uploadData($data, $destination, $contentType, $counter + 1, $totalChunks, $metadata); 229 | } 230 | 231 | return true; 232 | } 233 | 234 | /** 235 | * Abort Chunked Upload 236 | * 237 | * @param string $path 238 | * @param string $extra 239 | * @return bool 240 | */ 241 | public function abort(string $path, string $extra = ''): bool 242 | { 243 | if (file_exists($path)) { 244 | \unlink($path); 245 | } 246 | 247 | $tmp = \dirname($path).DIRECTORY_SEPARATOR.'tmp_'.\basename($path).DIRECTORY_SEPARATOR; 248 | 249 | if (! \file_exists(\dirname($tmp))) { // Checks if directory path to file exists 250 | throw new Exception('File doesn\'t exist: '.\dirname($path)); 251 | } 252 | $files = $this->getFiles($tmp); 253 | 254 | foreach ($files as $file) { 255 | $this->delete($file, true); 256 | } 257 | 258 | return \rmdir($tmp); 259 | } 260 | 261 | /** 262 | * Read file by given path. 263 | * 264 | * @param string $path 265 | * @param int offset 266 | * @param int|null $length 267 | * @return string 268 | * 269 | * @throws Exception 270 | */ 271 | public function read(string $path, int $offset = 0, int $length = null): string 272 | { 273 | if (! $this->exists($path)) { 274 | throw new Exception('File Not Found'); 275 | } 276 | 277 | return \file_get_contents($path, use_include_path: false, context: null, offset: $offset, length: $length); 278 | } 279 | 280 | /** 281 | * Write file by given path. 282 | * 283 | * @param string $path 284 | * @param string $data 285 | * @param string $contentType 286 | * @return bool 287 | */ 288 | public function write(string $path, string $data, string $contentType = ''): bool 289 | { 290 | if (! \file_exists(\dirname($path))) { // Checks if directory path to file exists 291 | if (! @\mkdir(\dirname($path), 0755, true)) { 292 | throw new Exception('Can\'t create directory '.\dirname($path)); 293 | } 294 | } 295 | 296 | return (bool) \file_put_contents($path, $data); 297 | } 298 | 299 | /** 300 | * Move file from given source to given path, Return true on success and false on failure. 301 | * 302 | * @see http://php.net/manual/en/function.filesize.php 303 | * 304 | * @param string $source 305 | * @param string $target 306 | * @return bool 307 | * 308 | * @throws Exception 309 | */ 310 | public function move(string $source, string $target): bool 311 | { 312 | if ($source === $target) { 313 | return false; 314 | } 315 | 316 | if (! \file_exists(\dirname($target))) { // Checks if directory path to file exists 317 | if (! @\mkdir(\dirname($target), 0755, true)) { 318 | throw new Exception('Can\'t create directory '.\dirname($target)); 319 | } 320 | } 321 | 322 | if (\rename($source, $target)) { 323 | return true; 324 | } 325 | 326 | return false; 327 | } 328 | 329 | /** 330 | * Delete file in given path, Return true on success and false on failure. 331 | * 332 | * @see http://php.net/manual/en/function.filesize.php 333 | * 334 | * @param string $path 335 | * @param bool $recursive 336 | * @return bool 337 | */ 338 | public function delete(string $path, bool $recursive = false): bool 339 | { 340 | if (\is_dir($path) && $recursive) { 341 | $files = $this->getFiles($path); 342 | 343 | foreach ($files as $file) { 344 | $this->delete($file, true); 345 | } 346 | 347 | \rmdir($path); 348 | } elseif (\is_file($path) || \is_link($path)) { 349 | return \unlink($path); 350 | } 351 | 352 | return false; 353 | } 354 | 355 | /** 356 | * Delete files in given path, path must be a directory. Return true on success and false on failure. 357 | * 358 | * @param string $path 359 | * @return bool 360 | */ 361 | public function deletePath(string $path): bool 362 | { 363 | $path = realpath($this->getRoot().DIRECTORY_SEPARATOR.$path); 364 | 365 | if (! file_exists($path) || ! is_dir($path)) { 366 | return false; 367 | } 368 | 369 | $files = $this->getFiles($path); 370 | 371 | foreach ($files as $file) { 372 | if (is_dir($file)) { 373 | $this->deletePath(\substr_replace($file, '', 0, \strlen($this->getRoot().DIRECTORY_SEPARATOR))); 374 | } else { 375 | $this->delete($file, true); 376 | } 377 | } 378 | 379 | return \rmdir($path); 380 | } 381 | 382 | /** 383 | * Check if file exists 384 | * 385 | * @param string $path 386 | * @return bool 387 | */ 388 | public function exists(string $path): bool 389 | { 390 | return \file_exists($path); 391 | } 392 | 393 | /** 394 | * Returns given file path its size. 395 | * 396 | * @see http://php.net/manual/en/function.filesize.php 397 | * 398 | * @param string $path 399 | * @return int 400 | */ 401 | public function getFileSize(string $path): int 402 | { 403 | return \filesize($path); 404 | } 405 | 406 | /** 407 | * Returns given file path its mime type. 408 | * 409 | * @see http://php.net/manual/en/function.mime-content-type.php 410 | * 411 | * @param string $path 412 | * @return string 413 | */ 414 | public function getFileMimeType(string $path): string 415 | { 416 | return \mime_content_type($path); 417 | } 418 | 419 | /** 420 | * Returns given file path its MD5 hash value. 421 | * 422 | * @see http://php.net/manual/en/function.md5-file.php 423 | * 424 | * @param string $path 425 | * @return string 426 | */ 427 | public function getFileHash(string $path): string 428 | { 429 | return \md5_file($path); 430 | } 431 | 432 | /** 433 | * Create a directory at the specified path. 434 | * 435 | * Returns true on success or if the directory already exists and false on error 436 | * 437 | * @param $path 438 | * @return bool 439 | */ 440 | public function createDirectory(string $path): bool 441 | { 442 | if (! \file_exists($path)) { 443 | if (! @\mkdir($path, 0755, true)) { 444 | return false; 445 | } 446 | } 447 | 448 | return true; 449 | } 450 | 451 | /** 452 | * Get directory size in bytes. 453 | * 454 | * Return -1 on error 455 | * 456 | * Based on http://www.jonasjohn.de/snippets/php/dir-size.htm 457 | * 458 | * @param string $path 459 | * @return int 460 | */ 461 | public function getDirectorySize(string $path): int 462 | { 463 | $size = 0; 464 | 465 | $directory = \opendir($path); 466 | 467 | if (! $directory) { 468 | return -1; 469 | } 470 | 471 | while (($file = \readdir($directory)) !== false) { 472 | // Skip file pointers 473 | if ($file[0] === '.') { 474 | continue; 475 | } 476 | 477 | // Go recursive down, or add the file size 478 | if (\is_dir($path.$file)) { 479 | $size += $this->getDirectorySize($path.$file.DIRECTORY_SEPARATOR); 480 | } else { 481 | $size += \filesize($path.$file); 482 | } 483 | } 484 | 485 | \closedir($directory); 486 | 487 | return $size; 488 | } 489 | 490 | /** 491 | * Get Partition Free Space. 492 | * 493 | * disk_free_space — Returns available space on filesystem or disk partition 494 | * 495 | * @return float 496 | */ 497 | public function getPartitionFreeSpace(): float 498 | { 499 | return \disk_free_space($this->getRoot()); 500 | } 501 | 502 | /** 503 | * Get Partition Total Space. 504 | * 505 | * disk_total_space — Returns the total size of a filesystem or disk partition 506 | * 507 | * @return float 508 | */ 509 | public function getPartitionTotalSpace(): float 510 | { 511 | return \disk_total_space($this->getRoot()); 512 | } 513 | 514 | /** 515 | * Get all files and directories inside a directory. 516 | * 517 | * @param string $dir 518 | * @param int $max 519 | * @param string $continuationToken 520 | * @return string[] 521 | */ 522 | public function getFiles(string $dir, int $max = self::MAX_PAGE_SIZE, string $continuationToken = ''): array 523 | { 524 | $dir = rtrim($dir, DIRECTORY_SEPARATOR); 525 | $files = []; 526 | 527 | foreach (\glob($dir.DIRECTORY_SEPARATOR.'*') as $file) { 528 | $files[] = $file; 529 | } 530 | 531 | /** 532 | * Hidden files 533 | */ 534 | foreach (\glob($dir.DIRECTORY_SEPARATOR.'.[!.]*') as $file) { 535 | $files[] = $file; 536 | } 537 | 538 | return $files; 539 | } 540 | } 541 | -------------------------------------------------------------------------------- /src/Storage/Device/S3.php: -------------------------------------------------------------------------------- 1 | '', 144 | 'date' => '', 145 | 'content-md5' => '', 146 | 'content-type' => '', 147 | ]; 148 | 149 | /** 150 | * @var array 151 | */ 152 | protected array $amzHeaders; 153 | 154 | /** 155 | * Http version 156 | * 157 | * @var int|null 158 | */ 159 | protected ?int $curlHttpVersion = null; 160 | 161 | /** 162 | * S3 Constructor 163 | * 164 | * @param string $root 165 | * @param string $accessKey 166 | * @param string $secretKey 167 | * @param string $bucket 168 | * @param string $region 169 | * @param string $acl 170 | */ 171 | public function __construct(string $root, string $accessKey, string $secretKey, string $bucket, string $region = self::US_EAST_1, string $acl = self::ACL_PRIVATE, $endpointUrl = '') 172 | { 173 | $this->accessKey = $accessKey; 174 | $this->secretKey = $secretKey; 175 | $this->bucket = $bucket; 176 | $this->region = $region; 177 | $this->root = $root; 178 | $this->acl = $acl; 179 | $this->amzHeaders = []; 180 | 181 | if (! empty($endpointUrl)) { 182 | $host = $bucket.'.'.$endpointUrl; 183 | } else { 184 | $host = match ($region) { 185 | self::CN_NORTH_1, self::CN_NORTH_4, self::CN_NORTHWEST_1 => $bucket.'.s3.'.$region.'.amazonaws.cn', 186 | default => $bucket.'.s3.'.$region.'.amazonaws.com' 187 | }; 188 | } 189 | 190 | $this->headers['host'] = $host; 191 | } 192 | 193 | /** 194 | * @return string 195 | */ 196 | public function getName(): string 197 | { 198 | return 'S3 Storage'; 199 | } 200 | 201 | /** 202 | * @return string 203 | */ 204 | public function getType(): string 205 | { 206 | return Storage::DEVICE_S3; 207 | } 208 | 209 | /** 210 | * @return string 211 | */ 212 | public function getDescription(): string 213 | { 214 | return 'S3 Bucket Storage drive for AWS or on premise solution'; 215 | } 216 | 217 | /** 218 | * @return string 219 | */ 220 | public function getRoot(): string 221 | { 222 | return $this->root; 223 | } 224 | 225 | /** 226 | * @param string $filename 227 | * @param string|null $prefix 228 | * @return string 229 | */ 230 | public function getPath(string $filename, string $prefix = null): string 231 | { 232 | return $this->getRoot().DIRECTORY_SEPARATOR.$filename; 233 | } 234 | 235 | /** 236 | * Set http version 237 | * 238 | * 239 | * @param int|null $httpVersion 240 | * @return self 241 | */ 242 | public function setHttpVersion(?int $httpVersion): self 243 | { 244 | $this->curlHttpVersion = $httpVersion; 245 | 246 | return $this; 247 | } 248 | 249 | /** 250 | * Set retry attempts 251 | * 252 | * @param int $attempts 253 | * @return void 254 | */ 255 | public static function setRetryAttempts(int $attempts) 256 | { 257 | self::$retryAttempts = $attempts; 258 | } 259 | 260 | /** 261 | * Set retry delay in milliseconds 262 | * 263 | * @param int $delay 264 | * @return void 265 | */ 266 | public static function setRetryDelay(int $delay): void 267 | { 268 | self::$retryDelay = $delay; 269 | } 270 | 271 | /** 272 | * Upload. 273 | * 274 | * Upload a file to desired destination in the selected disk. 275 | * return number of chunks uploaded or 0 if it fails. 276 | * 277 | * @param string $source 278 | * @param string $path 279 | * @param int chunk 280 | * @param int chunks 281 | * @param array $metadata 282 | * @return int 283 | * 284 | * @throws \Exception 285 | */ 286 | public function upload(string $source, string $path, int $chunk = 1, int $chunks = 1, array &$metadata = []): int 287 | { 288 | return $this->uploadData(\file_get_contents($source), $path, \mime_content_type($source), $chunk, $chunks, $metadata); 289 | } 290 | 291 | /** 292 | * Upload Data. 293 | * 294 | * Upload file contents to desired destination in the selected disk. 295 | * return number of chunks uploaded or 0 if it fails. 296 | * 297 | * @param string $source 298 | * @param string $path 299 | * @param string $contentType 300 | * @param int chunk 301 | * @param int chunks 302 | * @param array $metadata 303 | * @return int 304 | * 305 | * @throws \Exception 306 | */ 307 | public function uploadData(string $data, string $path, string $contentType, int $chunk = 1, int $chunks = 1, array &$metadata = []): int 308 | { 309 | if ($chunk == 1 && $chunks == 1) { 310 | return $this->write($path, $data, $contentType); 311 | } 312 | $uploadId = $metadata['uploadId'] ?? null; 313 | if (empty($uploadId)) { 314 | $uploadId = $this->createMultipartUpload($path, $contentType); 315 | $metadata['uploadId'] = $uploadId; 316 | } 317 | 318 | $metadata['parts'] ??= []; 319 | $metadata['chunks'] ??= 0; 320 | 321 | $etag = $this->uploadPart($data, $path, $contentType, $chunk, $uploadId); 322 | // skip incrementing if the chunk was re-uploaded 323 | if (! array_key_exists($chunk, $metadata['parts'])) { 324 | $metadata['chunks']++; 325 | } 326 | $metadata['parts'][$chunk] = $etag; 327 | if ($metadata['chunks'] == $chunks) { 328 | $this->completeMultipartUpload($path, $uploadId, $metadata['parts']); 329 | } 330 | 331 | return $metadata['chunks']; 332 | } 333 | 334 | /** 335 | * Transfer 336 | * 337 | * @param string $path 338 | * @param string $destination 339 | * @param Device $device 340 | * @return string 341 | */ 342 | public function transfer(string $path, string $destination, Device $device): bool 343 | { 344 | $response = []; 345 | try { 346 | $response = $this->getInfo($path); 347 | } catch (\Throwable $e) { 348 | throw new Exception('File not found'); 349 | } 350 | $size = (int) ($response['content-length'] ?? 0); 351 | $contentType = $response['content-type'] ?? ''; 352 | 353 | if ($size <= $this->transferChunkSize) { 354 | $source = $this->read($path); 355 | 356 | return $device->write($destination, $source, $contentType); 357 | } 358 | 359 | $totalChunks = \ceil($size / $this->transferChunkSize); 360 | $metadata = ['content_type' => $contentType]; 361 | for ($counter = 0; $counter < $totalChunks; $counter++) { 362 | $start = $counter * $this->transferChunkSize; 363 | $data = $this->read($path, $start, $this->transferChunkSize); 364 | $device->uploadData($data, $destination, $contentType, $counter + 1, $totalChunks, $metadata); 365 | } 366 | 367 | return true; 368 | } 369 | 370 | /** 371 | * Start Multipart Upload 372 | * 373 | * Initiate a multipart upload and return an upload ID. 374 | * 375 | * @param string $path 376 | * @param string $contentType 377 | * @return string 378 | * 379 | * @throws \Exception 380 | */ 381 | protected function createMultipartUpload(string $path, string $contentType): string 382 | { 383 | $uri = $path !== '' ? '/'.\str_replace(['%2F', '%3F'], ['/', '?'], \rawurlencode($path)) : '/'; 384 | 385 | $this->headers['content-md5'] = \base64_encode(md5('', true)); 386 | unset($this->amzHeaders['x-amz-content-sha256']); 387 | $this->headers['content-type'] = $contentType; 388 | $this->amzHeaders['x-amz-acl'] = $this->acl; 389 | $response = $this->call(self::METHOD_POST, $uri, '', ['uploads' => '']); 390 | 391 | return $response->body['UploadId']; 392 | } 393 | 394 | /** 395 | * Upload Part 396 | * 397 | * @param string $source 398 | * @param string $path 399 | * @param int $chunk 400 | * @param string $uploadId 401 | * @return string 402 | * 403 | * @throws \Exception 404 | */ 405 | protected function uploadPart(string $data, string $path, string $contentType, int $chunk, string $uploadId): string 406 | { 407 | $uri = $path !== '' ? '/'.\str_replace(['%2F', '%3F'], ['/', '?'], \rawurlencode($path)) : '/'; 408 | 409 | $this->headers['content-type'] = $contentType; 410 | $this->headers['content-md5'] = \base64_encode(md5($data, true)); 411 | $this->amzHeaders['x-amz-content-sha256'] = \hash('sha256', $data); 412 | unset($this->amzHeaders['x-amz-acl']); // ACL header is not allowed in parts, only createMultipartUpload accepts this header. 413 | 414 | $response = $this->call(self::METHOD_PUT, $uri, $data, [ 415 | 'partNumber' => $chunk, 416 | 'uploadId' => $uploadId, 417 | ]); 418 | 419 | return $response->headers['etag']; 420 | } 421 | 422 | /** 423 | * Complete Multipart Upload 424 | * 425 | * @param string $path 426 | * @param string $uploadId 427 | * @param array $parts 428 | * @return bool 429 | * 430 | * @throws \Exception 431 | */ 432 | protected function completeMultipartUpload(string $path, string $uploadId, array $parts): bool 433 | { 434 | $uri = $path !== '' ? '/'.\str_replace(['%2F', '%3F'], ['/', '?'], \rawurlencode($path)) : '/'; 435 | 436 | $body = ''; 437 | foreach ($parts as $key => $etag) { 438 | $body .= "{$etag}{$key}"; 439 | } 440 | $body .= ''; 441 | 442 | $this->amzHeaders['x-amz-content-sha256'] = \hash('sha256', $body); 443 | $this->headers['content-md5'] = \base64_encode(md5($body, true)); 444 | $this->call(self::METHOD_POST, $uri, $body, ['uploadId' => $uploadId]); 445 | 446 | return true; 447 | } 448 | 449 | /** 450 | * Abort Chunked Upload 451 | * 452 | * @param string $path 453 | * @param string $extra 454 | * @return bool 455 | * 456 | * @throws \Exception 457 | */ 458 | public function abort(string $path, string $extra = ''): bool 459 | { 460 | $uri = $path !== '' ? '/'.\str_replace(['%2F', '%3F'], ['/', '?'], \rawurlencode($path)) : '/'; 461 | unset($this->headers['content-type']); 462 | $this->headers['content-md5'] = \base64_encode(md5('', true)); 463 | $this->call(self::METHOD_DELETE, $uri, '', ['uploadId' => $extra]); 464 | 465 | return true; 466 | } 467 | 468 | /** 469 | * Read file or part of file by given path, offset and length. 470 | * 471 | * @param string $path 472 | * @param int offset 473 | * @param int length 474 | * @return string 475 | * 476 | * @throws \Exception 477 | */ 478 | public function read(string $path, int $offset = 0, int $length = null): string 479 | { 480 | unset($this->amzHeaders['x-amz-acl']); 481 | unset($this->amzHeaders['x-amz-content-sha256']); 482 | unset($this->headers['content-type']); 483 | $this->headers['content-md5'] = \base64_encode(md5('', true)); 484 | $uri = ($path !== '') ? '/'.\str_replace('%2F', '/', \rawurlencode($path)) : '/'; 485 | if ($length !== null) { 486 | $end = $offset + $length - 1; 487 | $this->headers['range'] = "bytes=$offset-$end"; 488 | } 489 | $response = $this->call(self::METHOD_GET, $uri, decode: false); 490 | 491 | return $response->body; 492 | } 493 | 494 | /** 495 | * Write file by given path. 496 | * 497 | * @param string $path 498 | * @param string $data 499 | * @return bool 500 | * 501 | * @throws \Exception 502 | */ 503 | public function write(string $path, string $data, string $contentType = ''): bool 504 | { 505 | $uri = $path !== '' ? '/'.\str_replace(['%2F', '%3F'], ['/', '?'], \rawurlencode($path)) : '/'; 506 | 507 | $this->headers['content-type'] = $contentType; 508 | $this->headers['content-md5'] = \base64_encode(md5($data, true)); //TODO whould this work well with big file? can we skip it? 509 | $this->amzHeaders['x-amz-content-sha256'] = \hash('sha256', $data); 510 | $this->amzHeaders['x-amz-acl'] = $this->acl; 511 | 512 | $this->call(self::METHOD_PUT, $uri, $data); 513 | 514 | return true; 515 | } 516 | 517 | /** 518 | * Delete file in given path, Return true on success and false on failure. 519 | * 520 | * @see http://php.net/manual/en/function.filesize.php 521 | * 522 | * @param string $path 523 | * @return bool 524 | * 525 | * @throws \Exception 526 | */ 527 | public function delete(string $path, bool $recursive = false): bool 528 | { 529 | $uri = ($path !== '') ? '/'.\str_replace('%2F', '/', \rawurlencode($path)) : '/'; 530 | 531 | unset($this->headers['content-type']); 532 | unset($this->amzHeaders['x-amz-acl']); 533 | unset($this->amzHeaders['x-amz-content-sha256']); 534 | $this->headers['content-md5'] = \base64_encode(md5('', true)); 535 | $this->call(self::METHOD_DELETE, $uri); 536 | 537 | return true; 538 | } 539 | 540 | /** 541 | * Get list of objects in the given path. 542 | * 543 | * @param string $prefix 544 | * @param int $maxKeys 545 | * @param string $continuationToken 546 | * @return array 547 | * 548 | * @throws Exception 549 | */ 550 | protected function listObjects(string $prefix = '', int $maxKeys = self::MAX_PAGE_SIZE, string $continuationToken = ''): array 551 | { 552 | if ($maxKeys > self::MAX_PAGE_SIZE) { 553 | throw new Exception('Cannot list more than '.self::MAX_PAGE_SIZE.' objects'); 554 | } 555 | 556 | $uri = '/'; 557 | $prefix = ltrim($prefix, '/'); /** S3 specific requirement that prefix should never contain a leading slash */ 558 | $this->headers['content-type'] = 'text/plain'; 559 | $this->headers['content-md5'] = \base64_encode(md5('', true)); 560 | 561 | unset($this->amzHeaders['x-amz-content-sha256']); 562 | unset($this->amzHeaders['x-amz-acl']); 563 | 564 | $parameters = [ 565 | 'list-type' => 2, 566 | 'prefix' => $prefix, 567 | 'max-keys' => $maxKeys, 568 | ]; 569 | 570 | if (! empty($continuationToken)) { 571 | $parameters['continuation-token'] = $continuationToken; 572 | } 573 | 574 | $response = $this->call(self::METHOD_GET, $uri, '', $parameters); 575 | 576 | return $response->body; 577 | } 578 | 579 | /** 580 | * Delete files in given path, path must be a directory. Return true on success and false on failure. 581 | * 582 | * @param string $path 583 | * @return bool 584 | * 585 | * @throws \Exception 586 | */ 587 | public function deletePath(string $path): bool 588 | { 589 | $path = $this->getRoot().'/'.$path; 590 | 591 | $uri = '/'; 592 | $continuationToken = ''; 593 | do { 594 | $objects = $this->listObjects($path, continuationToken: $continuationToken); 595 | $count = (int) ($objects['KeyCount'] ?? 1); 596 | if ($count < 1) { 597 | break; 598 | } 599 | $continuationToken = $objects['NextContinuationToken'] ?? ''; 600 | $body = ''; 601 | if ($count > 1) { 602 | foreach ($objects['Contents'] as $object) { 603 | $body .= "{$object['Key']}"; 604 | } 605 | } else { 606 | $body .= "{$objects['Contents']['Key']}"; 607 | } 608 | $body .= 'true'; 609 | $body .= ''; 610 | $this->amzHeaders['x-amz-content-sha256'] = \hash('sha256', $body); 611 | $this->headers['content-md5'] = \base64_encode(md5($body, true)); 612 | $this->call(self::METHOD_POST, $uri, $body, ['delete' => '']); 613 | } while (! empty($continuationToken)); 614 | 615 | return true; 616 | } 617 | 618 | /** 619 | * Check if file exists 620 | * 621 | * @param string $path 622 | * @return bool 623 | */ 624 | public function exists(string $path): bool 625 | { 626 | try { 627 | $this->getInfo($path); 628 | } catch (\Throwable $th) { 629 | return false; 630 | } 631 | 632 | return true; 633 | } 634 | 635 | /** 636 | * Returns given file path its size. 637 | * 638 | * @see http://php.net/manual/en/function.filesize.php 639 | * 640 | * @param string $path 641 | * @return int 642 | */ 643 | public function getFileSize(string $path): int 644 | { 645 | $response = $this->getInfo($path); 646 | 647 | return (int) ($response['content-length'] ?? 0); 648 | } 649 | 650 | /** 651 | * Returns given file path its mime type. 652 | * 653 | * @see http://php.net/manual/en/function.mime-content-type.php 654 | * 655 | * @param string $path 656 | * @return string 657 | */ 658 | public function getFileMimeType(string $path): string 659 | { 660 | $response = $this->getInfo($path); 661 | 662 | return $response['content-type'] ?? ''; 663 | } 664 | 665 | /** 666 | * Returns given file path its MD5 hash value. 667 | * 668 | * @see http://php.net/manual/en/function.md5-file.php 669 | * 670 | * @param string $path 671 | * @return string 672 | */ 673 | public function getFileHash(string $path): string 674 | { 675 | $etag = $this->getInfo($path)['etag'] ?? ''; 676 | 677 | return (! empty($etag)) ? substr($etag, 1, -1) : $etag; 678 | } 679 | 680 | /** 681 | * Create a directory at the specified path. 682 | * 683 | * Returns true on success or if the directory already exists and false on error 684 | * 685 | * @param $path 686 | * @return bool 687 | */ 688 | public function createDirectory(string $path): bool 689 | { 690 | /* S3 is an object store and does not have the concept of directories */ 691 | return true; 692 | } 693 | 694 | /** 695 | * Get directory size in bytes. 696 | * 697 | * Return -1 on error 698 | * 699 | * Based on http://www.jonasjohn.de/snippets/php/dir-size.htm 700 | * 701 | * @param string $path 702 | * @return int 703 | */ 704 | public function getDirectorySize(string $path): int 705 | { 706 | return -1; 707 | } 708 | 709 | /** 710 | * Get Partition Free Space. 711 | * 712 | * disk_free_space — Returns available space on filesystem or disk partition 713 | * 714 | * @return float 715 | */ 716 | public function getPartitionFreeSpace(): float 717 | { 718 | return -1; 719 | } 720 | 721 | /** 722 | * Get Partition Total Space. 723 | * 724 | * disk_total_space — Returns the total size of a filesystem or disk partition 725 | * 726 | * @return float 727 | */ 728 | public function getPartitionTotalSpace(): float 729 | { 730 | return -1; 731 | } 732 | 733 | /** 734 | * Get all files and directories inside a directory. 735 | * 736 | * @param string $dir Directory to scan 737 | * @param int $max 738 | * @param string $continuationToken 739 | * @return array 740 | * 741 | * @throws Exception 742 | */ 743 | public function getFiles(string $dir, int $max = self::MAX_PAGE_SIZE, string $continuationToken = ''): array 744 | { 745 | $data = $this->listObjects($dir, $max, $continuationToken); 746 | 747 | // Set to false if all the results were returned. Set to true if more keys are available to return. 748 | $data['IsTruncated'] = $data['IsTruncated'] === 'true'; 749 | 750 | // KeyCount is the number of keys returned with this request. 751 | $data['KeyCount'] = intval($data['KeyCount']); 752 | 753 | // Sets the maximum number of keys returned to the response. By default, the action returns up to 1,000 key names. 754 | $data['MaxKeys'] = intval($data['MaxKeys']); 755 | 756 | return $data; 757 | } 758 | 759 | /** 760 | * Get file info 761 | * 762 | * @param string $path 763 | * @return array 764 | * 765 | * @throws Exception 766 | */ 767 | private function getInfo(string $path): array 768 | { 769 | unset($this->headers['content-type']); 770 | unset($this->amzHeaders['x-amz-acl']); 771 | unset($this->amzHeaders['x-amz-content-sha256']); 772 | $this->headers['content-md5'] = \base64_encode(md5('', true)); 773 | $uri = $path !== '' ? '/'.\str_replace('%2F', '/', \rawurlencode($path)) : '/'; 774 | $response = $this->call(self::METHOD_HEAD, $uri); 775 | 776 | return $response->headers; 777 | } 778 | 779 | /** 780 | * Generate the headers for AWS Signature V4 781 | * 782 | * @param string $method 783 | * @param string $uri 784 | * @param array parameters 785 | * @return string 786 | */ 787 | private function getSignatureV4(string $method, string $uri, array $parameters = []): string 788 | { 789 | $service = 's3'; 790 | $region = $this->region; 791 | 792 | $algorithm = 'AWS4-HMAC-SHA256'; 793 | $combinedHeaders = []; 794 | 795 | $amzDateStamp = \substr($this->amzHeaders['x-amz-date'], 0, 8); 796 | 797 | // CanonicalHeaders 798 | foreach ($this->headers as $k => $v) { 799 | $combinedHeaders[\strtolower($k)] = \trim($v); 800 | } 801 | 802 | foreach ($this->amzHeaders as $k => $v) { 803 | $combinedHeaders[\strtolower($k)] = \trim($v); 804 | } 805 | 806 | uksort($combinedHeaders, [&$this, 'sortMetaHeadersCmp']); 807 | 808 | // Convert null query string parameters to strings and sort 809 | uksort($parameters, [&$this, 'sortMetaHeadersCmp']); 810 | $queryString = \http_build_query($parameters, '', '&', PHP_QUERY_RFC3986); 811 | 812 | // Payload 813 | $amzPayload = [$method]; 814 | 815 | $qsPos = \strpos($uri, '?'); 816 | $amzPayload[] = ($qsPos === false ? $uri : \substr($uri, 0, $qsPos)); 817 | 818 | $amzPayload[] = $queryString; 819 | 820 | foreach ($combinedHeaders as $k => $v) { // add header as string to requests 821 | $amzPayload[] = $k.':'.$v; 822 | } 823 | 824 | $amzPayload[] = ''; // add a blank entry so we end up with an extra line break 825 | $amzPayload[] = \implode(';', \array_keys($combinedHeaders)); // SignedHeaders 826 | $amzPayload[] = $this->amzHeaders['x-amz-content-sha256']; // payload hash 827 | 828 | $amzPayloadStr = \implode("\n", $amzPayload); // request as string 829 | 830 | // CredentialScope 831 | $credentialScope = [$amzDateStamp, $region, $service, 'aws4_request']; 832 | 833 | // stringToSign 834 | $stringToSignStr = \implode("\n", [ 835 | $algorithm, 836 | $this->amzHeaders['x-amz-date'], 837 | \implode('/', $credentialScope), 838 | \hash('sha256', $amzPayloadStr), 839 | ]); 840 | 841 | // Make Signature 842 | $kSecret = 'AWS4'.$this->secretKey; 843 | $kDate = \hash_hmac('sha256', $amzDateStamp, $kSecret, true); 844 | $kRegion = \hash_hmac('sha256', $region, $kDate, true); 845 | $kService = \hash_hmac('sha256', $service, $kRegion, true); 846 | $kSigning = \hash_hmac('sha256', 'aws4_request', $kService, true); 847 | 848 | $signature = \hash_hmac('sha256', \mb_convert_encoding($stringToSignStr, 'utf-8'), $kSigning); 849 | 850 | return $algorithm.' '.\implode(',', [ 851 | 'Credential='.$this->accessKey.'/'.\implode('/', $credentialScope), 852 | 'SignedHeaders='.\implode(';', \array_keys($combinedHeaders)), 853 | 'Signature='.$signature, 854 | ]); 855 | } 856 | 857 | /** 858 | * Get the S3 response 859 | * 860 | * @param string $method 861 | * @param string $uri 862 | * @param string $data 863 | * @param array $parameters 864 | * @param bool $decode 865 | * @return object 866 | * 867 | * @throws \Exception 868 | */ 869 | protected function call(string $method, string $uri, string $data = '', array $parameters = [], bool $decode = true) 870 | { 871 | $uri = $this->getAbsolutePath($uri); 872 | $url = 'https://'.$this->headers['host'].$uri.'?'.\http_build_query($parameters, '', '&', PHP_QUERY_RFC3986); 873 | $response = new \stdClass; 874 | $response->body = ''; 875 | $response->headers = []; 876 | 877 | // Basic setup 878 | $curl = \curl_init(); 879 | \curl_setopt($curl, CURLOPT_USERAGENT, 'utopia-php/storage'); 880 | \curl_setopt($curl, CURLOPT_URL, $url); 881 | 882 | // Headers 883 | $httpHeaders = []; 884 | $this->amzHeaders['x-amz-date'] = \gmdate('Ymd\THis\Z'); 885 | 886 | if (! isset($this->amzHeaders['x-amz-content-sha256'])) { 887 | $this->amzHeaders['x-amz-content-sha256'] = \hash('sha256', $data); 888 | } 889 | 890 | foreach ($this->amzHeaders as $header => $value) { 891 | if (\strlen($value) > 0) { 892 | $httpHeaders[] = $header.': '.$value; 893 | } 894 | } 895 | 896 | $this->headers['date'] = \gmdate('D, d M Y H:i:s T'); 897 | 898 | foreach ($this->headers as $header => $value) { 899 | if (\strlen($value) > 0) { 900 | $httpHeaders[] = $header.': '.$value; 901 | } 902 | } 903 | 904 | $httpHeaders[] = 'Authorization: '.$this->getSignatureV4($method, $uri, $parameters); 905 | 906 | \curl_setopt($curl, CURLOPT_HTTPHEADER, $httpHeaders); 907 | \curl_setopt($curl, CURLOPT_HEADER, false); 908 | \curl_setopt($curl, CURLOPT_RETURNTRANSFER, false); 909 | 910 | if ($this->curlHttpVersion != null) { 911 | \curl_setopt($curl, CURLOPT_HTTP_VERSION, $this->curlHttpVersion); 912 | } 913 | 914 | \curl_setopt($curl, CURLOPT_WRITEFUNCTION, function ($curl, string $data) use ($response) { 915 | $response->body .= $data; 916 | 917 | return \strlen($data); 918 | }); 919 | curl_setopt($curl, CURLOPT_HEADERFUNCTION, function ($curl, string $header) use (&$response) { 920 | $len = strlen($header); 921 | $header = explode(':', $header, 2); 922 | 923 | if (count($header) < 2) { // ignore invalid headers 924 | return $len; 925 | } 926 | 927 | $response->headers[strtolower(trim($header[0]))] = trim($header[1]); 928 | 929 | return $len; 930 | }); 931 | \curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true); 932 | \curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method); 933 | 934 | // Request types 935 | switch ($method) { 936 | case self::METHOD_PUT: 937 | case self::METHOD_POST: // POST only used for CloudFront 938 | \curl_setopt($curl, CURLOPT_POSTFIELDS, $data); 939 | break; 940 | case self::METHOD_HEAD: 941 | case self::METHOD_DELETE: 942 | \curl_setopt($curl, CURLOPT_NOBODY, true); 943 | break; 944 | } 945 | 946 | $result = \curl_exec($curl); 947 | 948 | $response->code = \curl_getinfo($curl, CURLINFO_HTTP_CODE); 949 | 950 | $attempt = 0; 951 | while ($attempt < self::$retryAttempts && $response->code === 503) { 952 | usleep(self::$retryDelay * 1000); 953 | $attempt++; 954 | $result = \curl_exec($curl); 955 | $response->code = \curl_getinfo($curl, CURLINFO_HTTP_CODE); 956 | } 957 | 958 | if (! $result) { 959 | throw new Exception(\curl_error($curl)); 960 | } 961 | 962 | if ($response->code >= 400) { 963 | throw new Exception($response->body, $response->code); 964 | } 965 | 966 | \curl_close($curl); 967 | 968 | // Parse body into XML 969 | if ($decode && ((isset($response->headers['content-type']) && $response->headers['content-type'] == 'application/xml') || (str_starts_with($response->body, 'headers['content-type'] ?? '') !== 'image/svg+xml'))) { 970 | $response->body = \simplexml_load_string($response->body); 971 | $response->body = json_decode(json_encode($response->body), true); 972 | } 973 | 974 | return $response; 975 | } 976 | 977 | /** 978 | * Sort compare for meta headers 979 | * 980 | * @internal Used to sort x-amz meta headers 981 | * 982 | * @param string $a String A 983 | * @param string $b String B 984 | * @return int 985 | */ 986 | private function sortMetaHeadersCmp($a, $b) 987 | { 988 | $lenA = \strlen($a); 989 | $lenB = \strlen($b); 990 | $minLen = \min($lenA, $lenB); 991 | $ncmp = \strncmp($a, $b, $minLen); 992 | if ($lenA == $lenB) { 993 | return $ncmp; 994 | } 995 | 996 | if (0 == $ncmp) { 997 | return $lenA < $lenB ? -1 : 1; 998 | } 999 | 1000 | return $ncmp; 1001 | } 1002 | } 1003 | -------------------------------------------------------------------------------- /src/Storage/Device/Wasabi.php: -------------------------------------------------------------------------------- 1 | headers['host'] = $bucket.'.'.'s3'.'.'.$region.'.'.'wasabisys'.'.'.'com'; 46 | } 47 | 48 | /** 49 | * @return string 50 | */ 51 | public function getName(): string 52 | { 53 | return 'Wasabi Storage'; 54 | } 55 | 56 | /** 57 | * @return string 58 | */ 59 | public function getDescription(): string 60 | { 61 | return 'Wasabi Storage'; 62 | } 63 | 64 | /** 65 | * @return string 66 | */ 67 | public function getType(): string 68 | { 69 | return Storage::DEVICE_WASABI; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Storage/Storage.php: -------------------------------------------------------------------------------- 1 | [ 97 | 'B', 98 | 'KiB', 99 | 'MiB', 100 | 'GiB', 101 | 'TiB', 102 | 'PiB', 103 | 'EiB', 104 | 'ZiB', 105 | 'YiB', 106 | ], 107 | 'metric' => [ 108 | 'B', 109 | 'kB', 110 | 'MB', 111 | 'GB', 112 | 'TB', 113 | 'PB', 114 | 'EB', 115 | 'ZB', 116 | 'YB', 117 | ], 118 | ]; 119 | 120 | $factor = (int) floor((strlen((string) $bytes) - 1) / 3); 121 | 122 | return sprintf("%.{$decimals}f%s", $bytes / pow($mod, $factor), $units[$system][$factor]); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Storage/Validator/File.php: -------------------------------------------------------------------------------- 1 | allowed = $allowed; 32 | } 33 | 34 | /** 35 | * Get Description 36 | */ 37 | public function getDescription(): string 38 | { 39 | return 'File extension is not valid'; 40 | } 41 | 42 | /** 43 | * Check if file extenstion is allowed 44 | * 45 | * @param mixed $filename 46 | * @return bool 47 | */ 48 | public function isValid($filename): bool 49 | { 50 | $ext = pathinfo($filename, PATHINFO_EXTENSION); 51 | $ext = strtolower($ext); 52 | 53 | if (! in_array($ext, $this->allowed)) { 54 | return false; 55 | } 56 | 57 | return true; 58 | } 59 | 60 | /** 61 | * Is array 62 | * 63 | * Function will return true if object is array. 64 | * 65 | * @return bool 66 | */ 67 | public function isArray(): bool 68 | { 69 | return false; 70 | } 71 | 72 | /** 73 | * Get Type 74 | * 75 | * Returns validator type. 76 | * 77 | * @return string 78 | */ 79 | public function getType(): string 80 | { 81 | return self::TYPE_STRING; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Storage/Validator/FileName.php: -------------------------------------------------------------------------------- 1 | max = $max; 22 | } 23 | 24 | /** 25 | * Get Description 26 | */ 27 | public function getDescription(): string 28 | { 29 | return 'File size can\'t be bigger than '.$this->max; 30 | } 31 | 32 | /** 33 | * Finds whether a file size is smaller than required limit. 34 | * 35 | * @param mixed $fileSize 36 | * @return bool 37 | */ 38 | public function isValid($fileSize): bool 39 | { 40 | if (! is_int($fileSize)) { 41 | return false; 42 | } 43 | 44 | if ($fileSize > $this->max) { 45 | return false; 46 | } 47 | 48 | return true; 49 | } 50 | 51 | /** 52 | * Is array 53 | * 54 | * Function will return true if object is array. 55 | * 56 | * @return bool 57 | */ 58 | public function isArray(): bool 59 | { 60 | return false; 61 | } 62 | 63 | /** 64 | * Get Type 65 | * 66 | * Returns validator type. 67 | * 68 | * @return string 69 | */ 70 | public function getType(): string 71 | { 72 | return self::TYPE_INTEGER; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Storage/Validator/FileType.php: -------------------------------------------------------------------------------- 1 | "\xFF\xD8\xFF", 28 | self::FILE_TYPE_GIF => 'GIF', 29 | self::FILE_TYPE_PNG => "\x89\x50\x4e\x47\x0d\x0a", 30 | self::FILE_TYPE_GZIP => 'application/x-gzip', 31 | ]; 32 | 33 | /** 34 | * @var array 35 | */ 36 | protected $allowed; 37 | 38 | /** 39 | * @param array $allowed 40 | * 41 | * @throws Exception 42 | */ 43 | public function __construct(array $allowed) 44 | { 45 | foreach ($allowed as $key) { 46 | if (! isset($this->types[$key])) { 47 | throw new Exception('Unknown file mime type'); 48 | } 49 | } 50 | 51 | $this->allowed = $allowed; 52 | } 53 | 54 | /** 55 | * Get Description 56 | */ 57 | public function getDescription(): string 58 | { 59 | return 'File mime-type is not allowed '; 60 | } 61 | 62 | /** 63 | * Is Valid. 64 | * 65 | * Binary check to finds whether a file is of valid type 66 | * 67 | * @see http://stackoverflow.com/a/3313196 68 | * 69 | * @param mixed $path 70 | * @return bool 71 | */ 72 | public function isValid($path): bool 73 | { 74 | if (! \is_readable($path)) { 75 | return false; 76 | } 77 | 78 | $handle = \fopen($path, 'r'); 79 | 80 | if (! $handle) { 81 | return false; 82 | } 83 | 84 | $bytes = \fgets($handle, 8); 85 | 86 | foreach ($this->allowed as $key) { 87 | if (\strpos($bytes, $this->types[$key]) === 0) { 88 | \fclose($handle); 89 | 90 | return true; 91 | } 92 | } 93 | 94 | \fclose($handle); 95 | 96 | return false; 97 | } 98 | 99 | /** 100 | * Is array 101 | * 102 | * Function will return true if object is array. 103 | * 104 | * @return bool 105 | */ 106 | public function isArray(): bool 107 | { 108 | return false; 109 | } 110 | 111 | /** 112 | * Get Type 113 | * 114 | * Returns validator type. 115 | * 116 | * @return string 117 | */ 118 | public function getType(): string 119 | { 120 | return self::TYPE_STRING; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Storage/Validator/Upload.php: -------------------------------------------------------------------------------- 1 | root = '/root'; 13 | $key = $_SERVER['BACKBLAZE_ACCESS_KEY'] ?? ''; 14 | $secret = $_SERVER['BACKBLAZE_SECRET'] ?? ''; 15 | $bucket = 'utopia-storage-test-new'; 16 | 17 | $this->object = new Backblaze($this->root, $key, $secret, $bucket, Backblaze::US_WEST_004, Backblaze::ACL_PRIVATE); 18 | } 19 | 20 | protected function getAdapterName(): string 21 | { 22 | return 'Backblaze B2 Storage'; 23 | } 24 | 25 | protected function getAdapterType(): string 26 | { 27 | return $this->object->getType(); 28 | } 29 | 30 | protected function getAdapterDescription(): string 31 | { 32 | return 'Backblaze B2 Storage'; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Storage/Device/DOSpacesTest.php: -------------------------------------------------------------------------------- 1 | root = '/root'; 13 | $key = $_SERVER['DO_ACCESS_KEY'] ?? ''; 14 | $secret = $_SERVER['DO_SECRET'] ?? ''; 15 | $bucket = 'utopia-storage-tests'; 16 | 17 | $this->object = new DOSpaces($this->root, $key, $secret, $bucket, DOSpaces::NYC3, DOSpaces::ACL_PUBLIC_READ); 18 | } 19 | 20 | protected function getAdapterName(): string 21 | { 22 | return 'Digitalocean Spaces Storage'; 23 | } 24 | 25 | protected function getAdapterType(): string 26 | { 27 | return $this->object->getType(); 28 | } 29 | 30 | protected function getAdapterDescription(): string 31 | { 32 | return 'Digitalocean Spaces Storage'; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Storage/Device/LinodeTest.php: -------------------------------------------------------------------------------- 1 | root = '/root'; 13 | $key = $_SERVER['LINODE_ACCESS_KEY'] ?? ''; 14 | $secret = $_SERVER['LINODE_SECRET'] ?? ''; 15 | $bucket = 'storage-test'; 16 | 17 | $this->object = new Linode($this->root, $key, $secret, $bucket, Linode::AP_SOUTH_1, Linode::ACL_PRIVATE); 18 | } 19 | 20 | protected function getAdapterName(): string 21 | { 22 | return 'Linode Object Storage'; 23 | } 24 | 25 | protected function getAdapterType(): string 26 | { 27 | return $this->object->getType(); 28 | } 29 | 30 | protected function getAdapterDescription(): string 31 | { 32 | return 'Linode Object Storage'; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Storage/Device/LocalTest.php: -------------------------------------------------------------------------------- 1 | object = new Local(realpath(__DIR__.'/../../resources/disk-a')); 19 | } 20 | 21 | public function tearDown(): void 22 | { 23 | } 24 | 25 | public function testPaths() 26 | { 27 | $this->assertEquals($this->object->getAbsolutePath('////storage/functions'), '/storage/functions'); 28 | $this->assertEquals($this->object->getAbsolutePath('storage/functions'), '/storage/functions'); 29 | $this->assertEquals($this->object->getAbsolutePath('/storage/functions'), '/storage/functions'); 30 | $this->assertEquals($this->object->getAbsolutePath('//storage///functions//'), '/storage/functions'); 31 | $this->assertEquals($this->object->getAbsolutePath('\\\\\storage\functions'), '/storage/functions'); 32 | $this->assertEquals($this->object->getAbsolutePath('..\\\\\//storage\\//functions'), '/storage/functions'); 33 | $this->assertEquals($this->object->getAbsolutePath('./..\\\\\//storage\\//functions'), '/storage/functions'); 34 | } 35 | 36 | public function testName() 37 | { 38 | $this->assertEquals($this->object->getName(), 'Local Storage'); 39 | } 40 | 41 | public function testType() 42 | { 43 | $this->assertEquals($this->object->getType(), 'local'); 44 | } 45 | 46 | public function testDescription() 47 | { 48 | $this->assertEquals($this->object->getDescription(), 'Adapter for Local storage that is in the physical or virtual machine or mounted to it.'); 49 | } 50 | 51 | public function testRoot() 52 | { 53 | $this->assertEquals($this->object->getRoot(), $this->object->getAbsolutePath(__DIR__.'/../../resources/disk-a')); 54 | } 55 | 56 | public function testPath() 57 | { 58 | $this->assertEquals($this->object->getPath('image.png'), $this->object->getAbsolutePath(__DIR__.'/../../resources/disk-a').'/image.png'); 59 | } 60 | 61 | public function testWrite() 62 | { 63 | $this->assertEquals($this->object->write($this->object->getPath('text.txt'), 'Hello World'), true); 64 | $this->assertEquals(file_exists($this->object->getPath('text.txt')), true); 65 | $this->assertEquals(is_readable($this->object->getPath('text.txt')), true); 66 | 67 | $this->object->delete($this->object->getPath('text.txt')); 68 | } 69 | 70 | public function testRead() 71 | { 72 | $this->assertEquals($this->object->write($this->object->getPath('text-for-read.txt'), 'Hello World'), true); 73 | $this->assertEquals($this->object->read($this->object->getPath('text-for-read.txt')), 'Hello World'); 74 | 75 | $this->object->delete($this->object->getPath('text-for-read.txt')); 76 | } 77 | 78 | public function testFileExists() 79 | { 80 | $this->assertEquals($this->object->write($this->object->getPath('text-for-test-exists.txt'), 'Hello World'), true); 81 | $this->assertEquals($this->object->exists($this->object->getPath('text-for-test-exists.txt')), true); 82 | $this->assertEquals($this->object->exists($this->object->getPath('text-for-test-doesnt-exist.txt')), false); 83 | 84 | $this->object->delete($this->object->getPath('text-for-test-exists.txt')); 85 | } 86 | 87 | public function testMove() 88 | { 89 | $this->assertEquals($this->object->write($this->object->getPath('text-for-move.txt'), 'Hello World'), true); 90 | $this->assertEquals($this->object->read($this->object->getPath('text-for-move.txt')), 'Hello World'); 91 | $this->assertEquals($this->object->move($this->object->getPath('text-for-move.txt'), $this->object->getPath('text-for-move-new.txt')), true); 92 | $this->assertEquals($this->object->read($this->object->getPath('text-for-move-new.txt')), 'Hello World'); 93 | $this->assertEquals(file_exists($this->object->getPath('text-for-move.txt')), false); 94 | $this->assertEquals(is_readable($this->object->getPath('text-for-move.txt')), false); 95 | $this->assertEquals(file_exists($this->object->getPath('text-for-move-new.txt')), true); 96 | $this->assertEquals(is_readable($this->object->getPath('text-for-move-new.txt')), true); 97 | 98 | $this->object->delete($this->object->getPath('text-for-move-new.txt')); 99 | } 100 | 101 | public function testDelete() 102 | { 103 | $this->assertEquals($this->object->write($this->object->getPath('text-for-delete.txt'), 'Hello World'), true); 104 | $this->assertEquals($this->object->read($this->object->getPath('text-for-delete.txt')), 'Hello World'); 105 | $this->assertEquals($this->object->delete($this->object->getPath('text-for-delete.txt')), true); 106 | $this->assertEquals(file_exists($this->object->getPath('text-for-delete.txt')), false); 107 | $this->assertEquals(is_readable($this->object->getPath('text-for-delete.txt')), false); 108 | } 109 | 110 | public function testFileSize() 111 | { 112 | $this->assertEquals($this->object->getFileSize(__DIR__.'/../../resources/disk-a/kitten-1.jpg'), 599639); 113 | $this->assertEquals($this->object->getFileSize(__DIR__.'/../../resources/disk-a/kitten-2.jpg'), 131958); 114 | } 115 | 116 | public function testFileMimeType() 117 | { 118 | $this->assertEquals($this->object->getFileMimeType(__DIR__.'/../../resources/disk-a/kitten-1.jpg'), 'image/jpeg'); 119 | $this->assertEquals($this->object->getFileMimeType(__DIR__.'/../../resources/disk-a/kitten-2.jpg'), 'image/jpeg'); 120 | $this->assertEquals($this->object->getFileMimeType(__DIR__.'/../../resources/disk-b/kitten-1.png'), 'image/png'); 121 | $this->assertEquals($this->object->getFileMimeType(__DIR__.'/../../resources/disk-b/kitten-2.png'), 'image/png'); 122 | } 123 | 124 | public function testFileHash() 125 | { 126 | $this->assertEquals($this->object->getFileHash(__DIR__.'/../../resources/disk-a/kitten-1.jpg'), '7551f343143d2e24ab4aaf4624996b6a'); 127 | $this->assertEquals($this->object->getFileHash(__DIR__.'/../../resources/disk-a/kitten-2.jpg'), '81702fdeef2e55b1a22617bce4951cb5'); 128 | $this->assertEquals($this->object->getFileHash(__DIR__.'/../../resources/disk-b/kitten-1.png'), '03010f4f02980521a8fd6213b52ec313'); 129 | $this->assertEquals($this->object->getFileHash(__DIR__.'/../../resources/disk-b/kitten-2.png'), '8a9ed992b77e4b62b10e3a5c8ed72062'); 130 | } 131 | 132 | public function testDirectoryCreate() 133 | { 134 | $directory = uniqid(); 135 | $this->assertTrue($this->object->createDirectory(__DIR__."/$directory")); 136 | $this->assertTrue($this->object->exists(__DIR__."/$directory")); 137 | } 138 | 139 | public function testDirectorySize() 140 | { 141 | $this->assertGreaterThan(0, $this->object->getDirectorySize(__DIR__.'/../../resources/disk-a/')); 142 | $this->assertGreaterThan(0, $this->object->getDirectorySize(__DIR__.'/../../resources/disk-b/')); 143 | } 144 | 145 | public function testPartUpload() 146 | { 147 | $source = __DIR__.'/../../resources/disk-a/large_file.mp4'; 148 | $dest = $this->object->getPath('uploaded.mp4'); 149 | $totalSize = $this->object->getFileSize($source); 150 | $chunkSize = 2097152; 151 | 152 | $chunks = ceil($totalSize / $chunkSize); 153 | 154 | $chunk = 1; 155 | $start = 0; 156 | 157 | $handle = @fopen($source, 'rb'); 158 | while ($start < $totalSize) { 159 | $contents = fread($handle, $chunkSize); 160 | $op = __DIR__.'/chunk.part'; 161 | $cc = fopen($op, 'wb'); 162 | fwrite($cc, $contents); 163 | fclose($cc); 164 | $this->object->upload($op, $dest, $chunk, $chunks); 165 | $start += strlen($contents); 166 | $chunk++; 167 | fseek($handle, $start); 168 | } 169 | @fclose($handle); 170 | $this->assertEquals(\filesize($source), $this->object->getFileSize($dest)); 171 | $this->assertEquals(\md5_file($source), $this->object->getFileHash($dest)); 172 | 173 | return $dest; 174 | } 175 | 176 | public function testPartUploadRetry() 177 | { 178 | $source = __DIR__.'/../../resources/disk-a/large_file.mp4'; 179 | $dest = $this->object->getPath('uploaded2.mp4'); 180 | $totalSize = \filesize($source); 181 | // AWS S3 requires each part to be at least 5MB except for last part 182 | $chunkSize = 5 * 1024 * 1024; 183 | 184 | $chunks = ceil($totalSize / $chunkSize); 185 | 186 | $chunk = 1; 187 | $start = 0; 188 | $handle = @fopen($source, 'rb'); 189 | $op = __DIR__.'/chunkx.part'; 190 | while ($start < $totalSize) { 191 | $contents = fread($handle, $chunkSize); 192 | $op = __DIR__.'/chunkx.part'; 193 | $cc = fopen($op, 'wb'); 194 | fwrite($cc, $contents); 195 | fclose($cc); 196 | $this->object->upload($op, $dest, $chunk, $chunks); 197 | $start += strlen($contents); 198 | $chunk++; 199 | if ($chunk == 2) { 200 | break; 201 | } 202 | fseek($handle, $start); 203 | } 204 | @fclose($handle); 205 | 206 | $chunk = 1; 207 | $start = 0; 208 | // retry from first to make sure duplicate chunk re-upload works without issue 209 | $handle = @fopen($source, 'rb'); 210 | $op = __DIR__.'/chunkx.part'; 211 | while ($start < $totalSize) { 212 | $contents = fread($handle, $chunkSize); 213 | $op = __DIR__.'/chunkx.part'; 214 | $cc = fopen($op, 'wb'); 215 | fwrite($cc, $contents); 216 | fclose($cc); 217 | $this->object->upload($op, $dest, $chunk, $chunks); 218 | $start += strlen($contents); 219 | $chunk++; 220 | fseek($handle, $start); 221 | } 222 | @fclose($handle); 223 | 224 | $this->assertEquals(\filesize($source), $this->object->getFileSize($dest)); 225 | $this->assertEquals(\md5_file($source), $this->object->getFileHash($dest)); 226 | 227 | return $dest; 228 | } 229 | 230 | public function testAbort() 231 | { 232 | $source = __DIR__.'/../../resources/disk-a/large_file.mp4'; 233 | $dest = $this->object->getPath('abcduploaded.mp4'); 234 | $totalSize = $this->object->getFileSize($source); 235 | $chunkSize = 2097152; 236 | $chunks = ceil($totalSize / $chunkSize); 237 | 238 | $chunk = 1; 239 | $start = 0; 240 | 241 | $handle = @fopen($source, 'rb'); 242 | while ($chunk < 3) { // only upload two chunks 243 | $contents = fread($handle, $chunkSize); 244 | $op = __DIR__.'/chunk.part'; 245 | $cc = fopen($op, 'wb'); 246 | fwrite($cc, $contents); 247 | fclose($cc); 248 | $this->object->upload($op, $dest, $chunk, $chunks); 249 | $start += strlen($contents); 250 | $chunk++; 251 | fseek($handle, $start); 252 | } 253 | @fclose($handle); 254 | 255 | // using file name with same first four chars 256 | $source = __DIR__.'/../../resources/disk-a/large_file.mp4'; 257 | $dest1 = $this->object->getPath('abcduploaded2.mp4'); 258 | $totalSize = $this->object->getFileSize($source); 259 | $chunkSize = 2097152; 260 | $chunks = ceil($totalSize / $chunkSize); 261 | 262 | $chunk = 1; 263 | $start = 0; 264 | 265 | $handle = @fopen($source, 'rb'); 266 | while ($chunk < 3) { // only upload two chunks 267 | $contents = fread($handle, $chunkSize); 268 | $op = __DIR__.'/chunk.part'; 269 | $cc = fopen($op, 'wb'); 270 | fwrite($cc, $contents); 271 | fclose($cc); 272 | $this->object->upload($op, $dest1, $chunk, $chunks); 273 | $start += strlen($contents); 274 | $chunk++; 275 | fseek($handle, $start); 276 | } 277 | @fclose($handle); 278 | 279 | $this->assertTrue($this->object->abort($dest)); 280 | $this->assertTrue($this->object->abort($dest1)); 281 | } 282 | 283 | /** 284 | * @depends testPartUpload 285 | */ 286 | public function testPartRead($path) 287 | { 288 | $source = __DIR__.'/../../resources/disk-a/large_file.mp4'; 289 | $chunk = file_get_contents($source, false, null, 0, 500); 290 | $readChunk = $this->object->read($path, 0, 500); 291 | $this->assertEquals($chunk, $readChunk); 292 | } 293 | 294 | public function testPartitionFreeSpace() 295 | { 296 | $this->assertGreaterThan(0, $this->object->getPartitionFreeSpace()); 297 | } 298 | 299 | public function testPartitionTotalSpace() 300 | { 301 | $this->assertGreaterThan(0, $this->object->getPartitionTotalSpace()); 302 | } 303 | 304 | /** 305 | * @depends testPartUpload 306 | */ 307 | public function testTransferLarge($path) 308 | { 309 | // chunked file 310 | $this->object->setTransferChunkSize(10000000); //10 mb 311 | 312 | $key = $_SERVER['S3_ACCESS_KEY'] ?? ''; 313 | $secret = $_SERVER['S3_SECRET'] ?? ''; 314 | $bucket = 'utopia-storage-test'; 315 | 316 | $device = new S3('/root', $key, $secret, $bucket, S3::EU_CENTRAL_1, S3::ACL_PRIVATE); 317 | $destination = $device->getPath('largefile.mp4'); 318 | 319 | $this->assertTrue($this->object->transfer($path, $destination, $device)); 320 | $this->assertTrue($device->exists($destination)); 321 | $this->assertEquals($device->getFileMimeType($destination), 'video/mp4'); 322 | 323 | $device->delete($destination); 324 | $this->object->delete($path); 325 | } 326 | 327 | public function testTransferSmall() 328 | { 329 | $this->object->setTransferChunkSize(10000000); //10 mb 330 | 331 | $key = $_SERVER['S3_ACCESS_KEY'] ?? ''; 332 | $secret = $_SERVER['S3_SECRET'] ?? ''; 333 | $bucket = 'utopia-storage-test'; 334 | 335 | $device = new S3('/root', $key, $secret, $bucket, S3::EU_CENTRAL_1, S3::ACL_PRIVATE); 336 | 337 | $path = $this->object->getPath('text-for-read.txt'); 338 | $this->object->write($path, 'Hello World'); 339 | 340 | $destination = $device->getPath('hello.txt'); 341 | $this->assertTrue($this->object->transfer($path, $destination, $device)); 342 | $this->assertTrue($device->exists($destination)); 343 | $this->assertEquals($device->read($destination), 'Hello World'); 344 | 345 | $this->object->delete($path); 346 | $device->delete($destination); 347 | } 348 | 349 | public function testDeletePath() 350 | { 351 | // Test Single Object 352 | $path = $this->object->getPath('text-for-delete-path.txt'); 353 | $path = str_ireplace($this->object->getRoot(), $this->object->getRoot().DIRECTORY_SEPARATOR.'bucket', $path); 354 | $this->assertEquals(true, $this->object->write($path, 'Hello World', 'text/plain')); 355 | $this->assertEquals(true, $this->object->exists($path)); 356 | $this->assertEquals(true, $this->object->deletePath('bucket')); 357 | $this->assertEquals(false, $this->object->exists($path)); 358 | 359 | // Test Multiple Objects 360 | $path = $this->object->getPath('text-for-delete-path1.txt'); 361 | $path = str_ireplace($this->object->getRoot(), $this->object->getRoot().DIRECTORY_SEPARATOR.'bucket', $path); 362 | $this->assertEquals(true, $this->object->write($path, 'Hello World', 'text/plain')); 363 | $this->assertEquals(true, $this->object->exists($path)); 364 | 365 | $path2 = $this->object->getPath('text-for-delete-path2.txt'); 366 | $path2 = str_ireplace($this->object->getRoot(), $this->object->getRoot().DIRECTORY_SEPARATOR.'bucket', $path2); 367 | $this->assertEquals(true, $this->object->write($path2, 'Hello World', 'text/plain')); 368 | $this->assertEquals(true, $this->object->exists($path2)); 369 | 370 | $path3 = $this->object->getPath('.hidden.txt'); 371 | $path3 = str_ireplace($this->object->getRoot(), $this->object->getRoot().DIRECTORY_SEPARATOR.'bucket', $path3); 372 | $this->assertEquals(true, $this->object->write($path3, 'Hello World', 'text/plain')); 373 | $this->assertEquals(true, $this->object->exists($path3)); 374 | 375 | $this->assertEquals(true, $this->object->deletePath('bucket/')); 376 | $this->assertEquals(false, $this->object->exists($path)); 377 | $this->assertEquals(false, $this->object->exists($path2)); 378 | $this->assertEquals(false, $this->object->exists($path3)); 379 | } 380 | 381 | public function testGetFiles() 382 | { 383 | $dir = DIRECTORY_SEPARATOR.'get-files-test'; 384 | 385 | $this->assertTrue($this->object->createDirectory($dir)); 386 | 387 | $files = $this->object->getFiles($dir); 388 | $this->assertEquals(0, \count($files)); 389 | 390 | $this->object->write($dir.DIRECTORY_SEPARATOR.'new-file.txt', 'Hello World'); 391 | $this->object->write($dir.DIRECTORY_SEPARATOR.'new-file-two.txt', 'Hello World'); 392 | 393 | $files = $this->object->getFiles($dir); 394 | $this->assertEquals(2, \count($files)); 395 | } 396 | 397 | public function testNestedDeletePath() 398 | { 399 | $dir = $this->object->getPath('nested-delete-path-test'); 400 | $dir2 = $dir.DIRECTORY_SEPARATOR.'dir2'; 401 | $dir3 = $dir2.DIRECTORY_SEPARATOR.'dir3'; 402 | 403 | $this->assertTrue($this->object->createDirectory($dir)); 404 | $this->object->write($dir.DIRECTORY_SEPARATOR.'new-file.txt', 'Hello World'); 405 | $this->assertTrue($this->object->createDirectory($dir2)); 406 | $this->object->write($dir2.DIRECTORY_SEPARATOR.'new-file-2.txt', 'Hello World'); 407 | $this->assertTrue($this->object->createDirectory($dir3)); 408 | $this->object->write($dir3.DIRECTORY_SEPARATOR.'new-file-3.txt', 'Hello World'); 409 | 410 | $this->assertTrue($this->object->deletePath('nested-delete-path-test')); 411 | $this->assertFalse($this->object->exists($dir)); 412 | } 413 | } 414 | -------------------------------------------------------------------------------- /tests/Storage/Device/S3Test.php: -------------------------------------------------------------------------------- 1 | root = '/root'; 13 | $key = $_SERVER['S3_ACCESS_KEY'] ?? ''; 14 | $secret = $_SERVER['S3_SECRET'] ?? ''; 15 | $bucket = 'utopia-storage-test'; 16 | 17 | $this->object = new S3($this->root, $key, $secret, $bucket, S3::EU_CENTRAL_1, S3::ACL_PRIVATE); 18 | } 19 | 20 | /** 21 | * @return string 22 | */ 23 | protected function getAdapterName(): string 24 | { 25 | return 'S3 Storage'; 26 | } 27 | 28 | protected function getAdapterType(): string 29 | { 30 | return $this->object->getType(); 31 | } 32 | 33 | protected function getAdapterDescription(): string 34 | { 35 | return 'S3 Bucket Storage drive for AWS or on premise solution'; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Storage/Device/WasabiTest.php: -------------------------------------------------------------------------------- 1 | root = '/root'; 13 | $key = $_SERVER['WASABI_ACCESS_KEY'] ?? ''; 14 | $secret = $_SERVER['WASABI_SECRET'] ?? ''; 15 | $bucket = 'utopia-storage-tests'; 16 | 17 | $this->object = new Wasabi($this->root, $key, $secret, $bucket, Wasabi::EU_CENTRAL_1, WASABI::ACL_PRIVATE); 18 | } 19 | 20 | protected function getAdapterName(): string 21 | { 22 | return 'Wasabi Storage'; 23 | } 24 | 25 | protected function getAdapterType(): string 26 | { 27 | return $this->object->getType(); 28 | } 29 | 30 | protected function getAdapterDescription(): string 31 | { 32 | return 'Wasabi Storage'; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Storage/S3Base.php: -------------------------------------------------------------------------------- 1 | init(); 36 | $this->uploadTestFiles(); 37 | } 38 | 39 | private function uploadTestFiles() 40 | { 41 | $this->object->upload(__DIR__.'/../resources/disk-a/kitten-1.jpg', $this->object->getPath('testing/kitten-1.jpg')); 42 | $this->object->upload(__DIR__.'/../resources/disk-a/kitten-2.jpg', $this->object->getPath('testing/kitten-2.jpg')); 43 | $this->object->upload(__DIR__.'/../resources/disk-b/kitten-1.png', $this->object->getPath('testing/kitten-1.png')); 44 | $this->object->upload(__DIR__.'/../resources/disk-b/kitten-2.png', $this->object->getPath('testing/kitten-2.png')); 45 | } 46 | 47 | private function removeTestFiles() 48 | { 49 | $this->object->delete($this->object->getPath('testing/kitten-1.jpg')); 50 | $this->object->delete($this->object->getPath('testing/kitten-2.jpg')); 51 | $this->object->delete($this->object->getPath('testing/kitten-1.png')); 52 | $this->object->delete($this->object->getPath('testing/kitten-2.png')); 53 | } 54 | 55 | public function tearDown(): void 56 | { 57 | $this->removeTestFiles(); 58 | } 59 | 60 | public function testGetFiles() 61 | { 62 | $path = $this->object->getPath('testing/'); 63 | $files = $this->object->getFiles($path); 64 | $this->assertEquals(4, $files['KeyCount']); 65 | $this->assertEquals(false, $files['IsTruncated']); 66 | $this->assertIsArray($files['Contents']); 67 | 68 | $file = $files['Contents'][0]; 69 | 70 | $this->assertArrayHasKey('Key', $file); 71 | $this->assertArrayHasKey('LastModified', $file); 72 | $this->assertArrayHasKey('ETag', $file); 73 | $this->assertArrayHasKey('StorageClass', $file); 74 | $this->assertArrayHasKey('Size', $file); 75 | } 76 | 77 | public function testGetFilesPagination() 78 | { 79 | $path = $this->object->getPath('testing/'); 80 | 81 | $files = $this->object->getFiles($path, 3); 82 | $this->assertEquals(3, $files['KeyCount']); 83 | $this->assertEquals(3, $files['MaxKeys']); 84 | $this->assertEquals(true, $files['IsTruncated']); 85 | $this->assertIsArray($files['Contents']); 86 | $this->assertArrayHasKey('NextContinuationToken', $files); 87 | 88 | $files = $this->object->getFiles($path, 1000, $files['NextContinuationToken']); 89 | $this->assertEquals(1, $files['KeyCount']); 90 | $this->assertEquals(1000, $files['MaxKeys']); 91 | $this->assertEquals(false, $files['IsTruncated']); 92 | $this->assertIsArray($files['Contents']); 93 | $this->assertArrayNotHasKey('NextContinuationToken', $files); 94 | } 95 | 96 | public function testName() 97 | { 98 | $this->assertEquals($this->getAdapterName(), $this->object->getName()); 99 | } 100 | 101 | public function testType() 102 | { 103 | $this->assertEquals($this->getAdapterType(), $this->object->getType()); 104 | } 105 | 106 | public function testDescription() 107 | { 108 | $this->assertEquals($this->getAdapterDescription(), $this->object->getDescription()); 109 | } 110 | 111 | public function testRoot() 112 | { 113 | $this->assertEquals($this->root, $this->object->getRoot()); 114 | } 115 | 116 | public function testPath() 117 | { 118 | $this->assertEquals($this->root.'/image.png', $this->object->getPath('image.png')); 119 | } 120 | 121 | public function testWrite() 122 | { 123 | $this->assertEquals(true, $this->object->write($this->object->getPath('text.txt'), 'Hello World', 'text/plain')); 124 | 125 | $this->object->delete($this->object->getPath('text.txt')); 126 | } 127 | 128 | public function testRead() 129 | { 130 | $this->assertEquals(true, $this->object->write($this->object->getPath('text-for-read.txt'), 'Hello World', 'text/plain')); 131 | $this->assertEquals('Hello World', $this->object->read($this->object->getPath('text-for-read.txt'))); 132 | 133 | $this->object->delete($this->object->getPath('text-for-read.txt')); 134 | } 135 | 136 | public function testFileExists() 137 | { 138 | $this->assertEquals(true, $this->object->exists($this->object->getPath('testing/kitten-1.jpg'))); 139 | $this->assertEquals(false, $this->object->exists($this->object->getPath('testing/kitten-5.jpg'))); 140 | } 141 | 142 | public function testMove() 143 | { 144 | $this->assertEquals(true, $this->object->write($this->object->getPath('text-for-move.txt'), 'Hello World', 'text/plain')); 145 | $this->assertEquals(true, $this->object->exists($this->object->getPath('text-for-move.txt'))); 146 | $this->assertEquals(true, $this->object->move($this->object->getPath('text-for-move.txt'), $this->object->getPath('text-for-move-new.txt'))); 147 | $this->assertEquals('Hello World', $this->object->read($this->object->getPath('text-for-move-new.txt'))); 148 | $this->assertEquals(false, $this->object->exists($this->object->getPath('text-for-move.txt'))); 149 | 150 | $this->object->delete($this->object->getPath('text-for-move-new.txt')); 151 | } 152 | 153 | public function testMoveIdenticalName() 154 | { 155 | $file = '/kitten-1.jpg'; 156 | $this->assertFalse($this->object->move($file, $file)); 157 | } 158 | 159 | public function testDelete() 160 | { 161 | $this->assertEquals(true, $this->object->write($this->object->getPath('text-for-delete.txt'), 'Hello World', 'text/plain')); 162 | $this->assertEquals(true, $this->object->exists($this->object->getPath('text-for-delete.txt'))); 163 | $this->assertEquals(true, $this->object->delete($this->object->getPath('text-for-delete.txt'))); 164 | } 165 | 166 | public function testSVGUpload() 167 | { 168 | $this->assertEquals(true, $this->object->upload(__DIR__.'/../resources/disk-b/appwrite.svg', $this->object->getPath('testing/appwrite.svg'))); 169 | $this->assertEquals(file_get_contents(__DIR__.'/../resources/disk-b/appwrite.svg'), $this->object->read($this->object->getPath('testing/appwrite.svg'))); 170 | $this->assertEquals(true, $this->object->exists($this->object->getPath('testing/appwrite.svg'))); 171 | $this->assertEquals(true, $this->object->delete($this->object->getPath('testing/appwrite.svg'))); 172 | } 173 | 174 | public function testXMLUpload() 175 | { 176 | $this->assertEquals(true, $this->object->upload(__DIR__.'/../resources/disk-a/config.xml', $this->object->getPath('testing/config.xml'))); 177 | $this->assertEquals(file_get_contents(__DIR__.'/../resources/disk-a/config.xml'), $this->object->read($this->object->getPath('testing/config.xml'))); 178 | $this->assertEquals(true, $this->object->exists($this->object->getPath('testing/config.xml'))); 179 | $this->assertEquals(true, $this->object->delete($this->object->getPath('testing/config.xml'))); 180 | } 181 | 182 | public function testDeletePath() 183 | { 184 | // Test Single Object 185 | $path = $this->object->getPath('text-for-delete-path.txt'); 186 | $path = str_ireplace($this->object->getRoot(), $this->object->getRoot().DIRECTORY_SEPARATOR.'bucket', $path); 187 | $this->assertEquals(true, $this->object->write($path, 'Hello World', 'text/plain')); 188 | $this->assertEquals(true, $this->object->exists($path)); 189 | $this->assertEquals(true, $this->object->deletePath('bucket')); 190 | $this->assertEquals(false, $this->object->exists($path)); 191 | 192 | // Test Multiple Objects 193 | $path = $this->object->getPath('text-for-delete-path1.txt'); 194 | $path = str_ireplace($this->object->getRoot(), $this->object->getRoot().DIRECTORY_SEPARATOR.'bucket', $path); 195 | $this->assertEquals(true, $this->object->write($path, 'Hello World', 'text/plain')); 196 | $this->assertEquals(true, $this->object->exists($path)); 197 | 198 | $path2 = $this->object->getPath('text-for-delete-path2.txt'); 199 | $path2 = str_ireplace($this->object->getRoot(), $this->object->getRoot().DIRECTORY_SEPARATOR.'bucket', $path2); 200 | $this->assertEquals(true, $this->object->write($path2, 'Hello World', 'text/plain')); 201 | $this->assertEquals(true, $this->object->exists($path2)); 202 | 203 | $this->assertEquals(true, $this->object->deletePath('bucket')); 204 | $this->assertEquals(false, $this->object->exists($path)); 205 | $this->assertEquals(false, $this->object->exists($path2)); 206 | } 207 | 208 | public function testFileSize() 209 | { 210 | $this->assertEquals(599639, $this->object->getFileSize($this->object->getPath('testing/kitten-1.jpg'))); 211 | $this->assertEquals(131958, $this->object->getFileSize($this->object->getPath('testing/kitten-2.jpg'))); 212 | } 213 | 214 | public function testFileMimeType() 215 | { 216 | $this->assertEquals('image/jpeg', $this->object->getFileMimeType($this->object->getPath('testing/kitten-1.jpg'))); 217 | $this->assertEquals('image/jpeg', $this->object->getFileMimeType($this->object->getPath('testing/kitten-2.jpg'))); 218 | $this->assertEquals('image/png', $this->object->getFileMimeType($this->object->getPath('testing/kitten-1.png'))); 219 | $this->assertEquals('image/png', $this->object->getFileMimeType($this->object->getPath('testing/kitten-2.png'))); 220 | } 221 | 222 | public function testFileHash() 223 | { 224 | $this->assertEquals('7551f343143d2e24ab4aaf4624996b6a', $this->object->getFileHash($this->object->getPath('testing/kitten-1.jpg'))); 225 | $this->assertEquals('81702fdeef2e55b1a22617bce4951cb5', $this->object->getFileHash($this->object->getPath('testing/kitten-2.jpg'))); 226 | $this->assertEquals('03010f4f02980521a8fd6213b52ec313', $this->object->getFileHash($this->object->getPath('testing/kitten-1.png'))); 227 | $this->assertEquals('8a9ed992b77e4b62b10e3a5c8ed72062', $this->object->getFileHash($this->object->getPath('testing/kitten-2.png'))); 228 | } 229 | 230 | public function testDirectoryCreate() 231 | { 232 | $this->assertTrue($this->object->createDirectory('temp')); 233 | } 234 | 235 | public function testDirectorySize() 236 | { 237 | $this->assertEquals(-1, $this->object->getDirectorySize('resources/disk-a/')); 238 | } 239 | 240 | public function testPartitionFreeSpace() 241 | { 242 | $this->assertEquals(-1, $this->object->getPartitionFreeSpace()); 243 | } 244 | 245 | public function testPartitionTotalSpace() 246 | { 247 | $this->assertEquals(-1, $this->object->getPartitionTotalSpace()); 248 | } 249 | 250 | public function testPartUpload() 251 | { 252 | $source = __DIR__.'/../resources/disk-a/large_file.mp4'; 253 | $dest = $this->object->getPath('uploaded.mp4'); 254 | $totalSize = \filesize($source); 255 | // AWS S3 requires each part to be at least 5MB except for last part 256 | $chunkSize = 5 * 1024 * 1024; 257 | 258 | $chunks = ceil($totalSize / $chunkSize); 259 | 260 | $chunk = 1; 261 | $start = 0; 262 | 263 | $metadata = [ 264 | 'parts' => [], 265 | 'chunks' => 0, 266 | 'uploadId' => null, 267 | 'content_type' => \mime_content_type($source), 268 | ]; 269 | $handle = @fopen($source, 'rb'); 270 | $op = __DIR__.'/chunk.part'; 271 | while ($start < $totalSize) { 272 | $contents = fread($handle, $chunkSize); 273 | $op = __DIR__.'/chunk.part'; 274 | $cc = fopen($op, 'wb'); 275 | fwrite($cc, $contents); 276 | fclose($cc); 277 | $this->object->upload($op, $dest, $chunk, $chunks, $metadata); 278 | $start += strlen($contents); 279 | $chunk++; 280 | fseek($handle, $start); 281 | } 282 | @fclose($handle); 283 | unlink($op); 284 | 285 | $this->assertEquals(\filesize($source), $this->object->getFileSize($dest)); 286 | 287 | // S3 doesnt provide a method to get a proper MD5-hash of a file created using multipart upload 288 | // https://stackoverflow.com/questions/8618218/amazon-s3-checksum 289 | // More info on how AWS calculates ETag for multipart upload here 290 | // https://savjee.be/2015/10/Verifying-Amazon-S3-multi-part-uploads-with-ETag-hash/ 291 | // TODO 292 | // $this->assertEquals(\md5_file($source), $this->object->getFileHash($dest)); 293 | // $this->object->delete($dest); 294 | return $dest; 295 | } 296 | 297 | public function testPartUploadRetry() 298 | { 299 | $source = __DIR__.'/../resources/disk-a/large_file.mp4'; 300 | $dest = $this->object->getPath('uploaded.mp4'); 301 | $totalSize = \filesize($source); 302 | // AWS S3 requires each part to be at least 5MB except for last part 303 | $chunkSize = 5 * 1024 * 1024; 304 | 305 | $chunks = ceil($totalSize / $chunkSize); 306 | 307 | $chunk = 1; 308 | $start = 0; 309 | 310 | $metadata = [ 311 | 'parts' => [], 312 | 'chunks' => 0, 313 | 'uploadId' => null, 314 | 'content_type' => \mime_content_type($source), 315 | ]; 316 | $handle = @fopen($source, 'rb'); 317 | $op = __DIR__.'/chunk.part'; 318 | while ($start < $totalSize) { 319 | $contents = fread($handle, $chunkSize); 320 | $op = __DIR__.'/chunk.part'; 321 | $cc = fopen($op, 'wb'); 322 | fwrite($cc, $contents); 323 | fclose($cc); 324 | $this->object->upload($op, $dest, $chunk, $chunks, $metadata); 325 | $start += strlen($contents); 326 | $chunk++; 327 | if ($chunk == 2) { 328 | break; 329 | } 330 | fseek($handle, $start); 331 | } 332 | @fclose($handle); 333 | unlink($op); 334 | 335 | $chunk = 1; 336 | $start = 0; 337 | // retry from first to make sure duplicate chunk re-upload works without issue 338 | $handle = @fopen($source, 'rb'); 339 | $op = __DIR__.'/chunk.part'; 340 | while ($start < $totalSize) { 341 | $contents = fread($handle, $chunkSize); 342 | $op = __DIR__.'/chunk.part'; 343 | $cc = fopen($op, 'wb'); 344 | fwrite($cc, $contents); 345 | fclose($cc); 346 | $this->object->upload($op, $dest, $chunk, $chunks, $metadata); 347 | $start += strlen($contents); 348 | $chunk++; 349 | fseek($handle, $start); 350 | } 351 | @fclose($handle); 352 | unlink($op); 353 | 354 | $this->assertEquals(\filesize($source), $this->object->getFileSize($dest)); 355 | 356 | // S3 doesnt provide a method to get a proper MD5-hash of a file created using multipart upload 357 | // https://stackoverflow.com/questions/8618218/amazon-s3-checksum 358 | // More info on how AWS calculates ETag for multipart upload here 359 | // https://savjee.be/2015/10/Verifying-Amazon-S3-multi-part-uploads-with-ETag-hash/ 360 | // TODO 361 | // $this->assertEquals(\md5_file($source), $this->object->getFileHash($dest)); 362 | // $this->object->delete($dest); 363 | return $dest; 364 | } 365 | 366 | /** 367 | * @depends testPartUpload 368 | */ 369 | public function testPartRead($path) 370 | { 371 | $source = __DIR__.'/../resources/disk-a/large_file.mp4'; 372 | $chunk = file_get_contents($source, false, null, 0, 500); 373 | $readChunk = $this->object->read($path, 0, 500); 374 | $this->assertEquals($chunk, $readChunk); 375 | } 376 | 377 | /** 378 | * @depends testPartUpload 379 | */ 380 | public function testTransferLarge($path) 381 | { 382 | // chunked file 383 | $this->object->setTransferChunkSize(10000000); //10 mb 384 | 385 | $device = new Local(__DIR__.'/../resources/disk-a'); 386 | $destination = $device->getPath('largefile.mp4'); 387 | 388 | $this->assertTrue($this->object->transfer($path, $destination, $device)); 389 | $this->assertTrue($device->exists($destination)); 390 | $this->assertEquals($device->getFileMimeType($destination), 'video/mp4'); 391 | 392 | $device->delete($destination); 393 | $this->object->delete($path); 394 | } 395 | 396 | public function testTransferSmall() 397 | { 398 | $this->object->setTransferChunkSize(10000000); //10 mb 399 | 400 | $device = new Local(__DIR__.'/../resources/disk-a'); 401 | 402 | $path = $this->object->getPath('text-for-read.txt'); 403 | $this->object->write($path, 'Hello World', 'text/plain'); 404 | 405 | $destination = $device->getPath('hello.txt'); 406 | $this->assertTrue($this->object->transfer($path, $destination, $device)); 407 | $this->assertTrue($device->exists($destination)); 408 | $this->assertEquals($device->read($destination), 'Hello World'); 409 | 410 | $this->object->delete($path); 411 | $device->delete($destination); 412 | } 413 | } 414 | -------------------------------------------------------------------------------- /tests/Storage/StorageTest.php: -------------------------------------------------------------------------------- 1 | assertEquals(get_class(Storage::getDevice('disk-a')), 'Utopia\Storage\Device\Local'); 26 | $this->assertEquals(get_class(Storage::getDevice('disk-b')), 'Utopia\Storage\Device\Local'); 27 | 28 | try { 29 | get_class(Storage::getDevice('disk-c')); 30 | $this->fail('Expected exception not thrown'); 31 | } catch (Exception $e) { 32 | $this->assertEquals('The device "disk-c" is not listed', $e->getMessage()); 33 | } 34 | } 35 | 36 | public function testExists() 37 | { 38 | $this->assertEquals(Storage::exists('disk-a'), true); 39 | $this->assertEquals(Storage::exists('disk-b'), true); 40 | $this->assertEquals(Storage::exists('disk-c'), false); 41 | } 42 | 43 | public function testMoveIdenticalName() 44 | { 45 | $file = '/kitten-1.jpg'; 46 | $device = Storage::getDevice('disk-a'); 47 | $this->assertFalse($device->move($file, $file)); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/Storage/Validator/FileExtTest.php: -------------------------------------------------------------------------------- 1 | object = new FileExt([FileExt::TYPE_GIF, FileExt::TYPE_GZIP, FileExt::TYPE_ZIP]); 18 | } 19 | 20 | public function tearDown(): void 21 | { 22 | } 23 | 24 | public function testValues() 25 | { 26 | $this->assertEquals($this->object->isValid(''), false); 27 | $this->assertEquals($this->object->isValid(null), false); 28 | $this->assertEquals($this->object->isValid(false), false); 29 | $this->assertEquals($this->object->isValid('test'), false); 30 | $this->assertEquals($this->object->isValid('...test.png'), false); 31 | $this->assertEquals($this->object->isValid('.gif'), true); 32 | $this->assertEquals($this->object->isValid('x.gif'), true); 33 | $this->assertEquals($this->object->isValid('gif'), false); 34 | $this->assertEquals($this->object->isValid('file.tar'), false); 35 | $this->assertEquals($this->object->isValid('file.tar.g'), false); 36 | $this->assertEquals($this->object->isValid('file.tar.gz'), true); 37 | $this->assertEquals($this->object->isValid('file.gz'), true); 38 | $this->assertEquals($this->object->isValid('file.GIF'), true); 39 | $this->assertEquals($this->object->isValid('file.zip'), true); 40 | $this->assertEquals($this->object->isValid('file.7zip'), false); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/Storage/Validator/FileNameTest.php: -------------------------------------------------------------------------------- 1 | object = new FileName(); 18 | } 19 | 20 | public function tearDown(): void 21 | { 22 | } 23 | 24 | public function testValues() 25 | { 26 | $this->assertEquals($this->object->isValid(''), false); 27 | $this->assertEquals($this->object->isValid(null), false); 28 | $this->assertEquals($this->object->isValid(false), false); 29 | $this->assertEquals($this->object->isValid('../test'), false); 30 | $this->assertEquals($this->object->isValid('test.png'), true); 31 | $this->assertEquals($this->object->isValid('test'), true); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Storage/Validator/FileSizeTest.php: -------------------------------------------------------------------------------- 1 | object = new FileSize(1000); 18 | } 19 | 20 | public function tearDown(): void 21 | { 22 | } 23 | 24 | public function testValues() 25 | { 26 | $this->assertEquals($this->object->isValid(1001), false); 27 | $this->assertEquals($this->object->isValid(1000), true); 28 | $this->assertEquals($this->object->isValid(999), true); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Storage/Validator/FileTypeTest.php: -------------------------------------------------------------------------------- 1 | object = new FileType([FileType::FILE_TYPE_JPEG]); 18 | } 19 | 20 | public function tearDown(): void 21 | { 22 | } 23 | 24 | public function testValues() 25 | { 26 | $this->assertEquals($this->object->isValid(__DIR__.'/../../resources/disk-a/kitten-1.jpg'), true); 27 | $this->assertEquals($this->object->isValid(__DIR__.'/../../resources/disk-a/kitten-2.jpg'), true); 28 | $this->assertEquals($this->object->isValid(__DIR__.'/../../resources/disk-b/kitten-1.png'), false); 29 | $this->assertEquals($this->object->isValid(__DIR__.'/../../resources/disk-b/kitten-2.png'), false); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Storage/Validator/UploadTest.php: -------------------------------------------------------------------------------- 1 | object = new Upload(); 18 | } 19 | 20 | public function tearDown(): void 21 | { 22 | } 23 | 24 | public function testValues() 25 | { 26 | $this->assertEquals($this->object->isValid(__DIR__.'/../../resources/disk-a/kitten-1.jpg'), false); 27 | $this->assertEquals($this->object->isValid(__DIR__.'/../../resources/disk-a/kitten-2.jpg'), false); 28 | $this->assertEquals($this->object->isValid(__DIR__.'/../../resources/disk-b/kitten-1.png'), false); 29 | $this->assertEquals($this->object->isValid(__DIR__.'/../../resources/disk-b/kitten-2.png'), false); 30 | $this->assertEquals($this->object->isValid(__FILE__), false); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/resources/disk-a/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Charter Group 5 |
6 | 100 Main 7 | Framingham 8 | MA 9 | 01701 10 |
11 |
12 | 720 Prospect 13 | Framingham 14 | MA 15 | 01701 16 |
17 |
18 | 120 Ridge 19 | MA 20 | 01760 21 |
22 |
23 |
-------------------------------------------------------------------------------- /tests/resources/disk-a/kitten-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utopia-php/storage/71102ea98c3f05b017d109a93980d0cb6b98c7ae/tests/resources/disk-a/kitten-1.jpg -------------------------------------------------------------------------------- /tests/resources/disk-a/kitten-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utopia-php/storage/71102ea98c3f05b017d109a93980d0cb6b98c7ae/tests/resources/disk-a/kitten-2.jpg -------------------------------------------------------------------------------- /tests/resources/disk-a/kitten-3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utopia-php/storage/71102ea98c3f05b017d109a93980d0cb6b98c7ae/tests/resources/disk-a/kitten-3.gif -------------------------------------------------------------------------------- /tests/resources/disk-a/large_file.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utopia-php/storage/71102ea98c3f05b017d109a93980d0cb6b98c7ae/tests/resources/disk-a/large_file.mp4 -------------------------------------------------------------------------------- /tests/resources/disk-b/appwrite.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/resources/disk-b/kitten-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utopia-php/storage/71102ea98c3f05b017d109a93980d0cb6b98c7ae/tests/resources/disk-b/kitten-1.png -------------------------------------------------------------------------------- /tests/resources/disk-b/kitten-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utopia-php/storage/71102ea98c3f05b017d109a93980d0cb6b98c7ae/tests/resources/disk-b/kitten-2.png --------------------------------------------------------------------------------