├── .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 | [](https://travis-ci.com/utopia-php/storage)
4 | 
5 | [](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 .= "";
604 | }
605 | } else {
606 | $body .= "";
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 |
--------------------------------------------------------------------------------
/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
--------------------------------------------------------------------------------