├── .editorconfig ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .npmignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── src ├── __tests__ │ └── provider.spec.ts └── index.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [{package.json,*.yml}] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ############################ 2 | # OS X 3 | ############################ 4 | 5 | .DS_Store 6 | .AppleDouble 7 | .LSOverride 8 | Icon 9 | .Spotlight-V100 10 | .Trashes 11 | ._* 12 | 13 | 14 | ############################ 15 | # Linux 16 | ############################ 17 | 18 | *~ 19 | 20 | 21 | ############################ 22 | # Windows 23 | ############################ 24 | 25 | Thumbs.db 26 | ehthumbs.db 27 | Desktop.ini 28 | $RECYCLE.BIN/ 29 | *.cab 30 | *.msi 31 | *.msm 32 | *.msp 33 | 34 | 35 | ############################ 36 | # Packages 37 | ############################ 38 | 39 | *.7z 40 | *.csv 41 | *.dat 42 | *.dmg 43 | *.gz 44 | *.iso 45 | *.jar 46 | *.rar 47 | *.tar 48 | *.zip 49 | *.com 50 | *.class 51 | *.dll 52 | *.exe 53 | *.o 54 | *.seed 55 | *.so 56 | *.swo 57 | *.swp 58 | *.swn 59 | *.swm 60 | *.out 61 | *.pid 62 | 63 | 64 | ############################ 65 | # Logs and databases 66 | ############################ 67 | 68 | .tmp 69 | *.log 70 | *.sql 71 | *.sqlite 72 | 73 | 74 | ############################ 75 | # Misc. 76 | ############################ 77 | 78 | *# 79 | .idea 80 | nbproject 81 | 82 | 83 | ############################ 84 | # Node.js 85 | ############################ 86 | 87 | lib-cov 88 | lcov.info 89 | pids 90 | logs 91 | results 92 | build 93 | node_modules 94 | .node_history 95 | package-lock.json 96 | 97 | yarn.lock 98 | .env 99 | 100 | lib/ 101 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | ############################ 2 | # OS X 3 | ############################ 4 | 5 | .DS_Store 6 | .AppleDouble 7 | .LSOverride 8 | Icon 9 | .Spotlight-V100 10 | .Trashes 11 | ._* 12 | 13 | 14 | ############################ 15 | # Linux 16 | ############################ 17 | 18 | *~ 19 | 20 | 21 | ############################ 22 | # Windows 23 | ############################ 24 | 25 | Thumbs.db 26 | ehthumbs.db 27 | Desktop.ini 28 | $RECYCLE.BIN/ 29 | *.cab 30 | *.msi 31 | *.msm 32 | *.msp 33 | 34 | 35 | ############################ 36 | # Packages 37 | ############################ 38 | 39 | *.7z 40 | *.csv 41 | *.dat 42 | *.dmg 43 | *.gz 44 | *.iso 45 | *.jar 46 | *.rar 47 | *.tar 48 | *.zip 49 | *.com 50 | *.class 51 | *.dll 52 | *.exe 53 | *.o 54 | *.seed 55 | *.so 56 | *.swo 57 | *.swp 58 | *.swn 59 | *.swm 60 | *.out 61 | *.pid 62 | *.tgz 63 | 64 | 65 | ############################ 66 | # Logs and databases 67 | ############################ 68 | 69 | .tmp 70 | *.log 71 | *.sql 72 | *.sqlite 73 | 74 | 75 | ############################ 76 | # Misc. 77 | ############################ 78 | 79 | *# 80 | ssl 81 | .editorconfig 82 | .gitattributes 83 | .idea 84 | nbproject 85 | 86 | 87 | ############################ 88 | # Node.js 89 | ############################ 90 | 91 | lib-cov 92 | lcov.info 93 | pids 94 | logs 95 | results 96 | build 97 | node_modules 98 | .node_history 99 | .snyk 100 | 101 | 102 | 103 | ############################ 104 | # Tests 105 | ############################ 106 | 107 | test 108 | tests 109 | __tests__ 110 | jest.config.js 111 | 112 | # Our stuff 113 | .env 114 | src/ 115 | tsconfig.json 116 | CODE_OF_CONDUCT.md 117 | .github 118 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | occloxium@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Alexander Bartolomey 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # strapi-provider-upload-aws-s3-advanced 2 | 3 | > [!IMPORTANT] 4 | > This package is no longer maintained, most notably because all additions of this plugin were 5 | > implemented in the upstream plugin as well, namely `baseUrl`, `prefix`, and Typescript support. 6 | > Strapi maintains the in-tree plugin and keeps it up with NodeJS versions, which *this* plugin 7 | > no longer receives updates for. Because of breaking changes and active deprecation of the 8 | > AWS-SDKv2 causing too much work for us, we are retiring any further development of this plugin. 9 | > Please use strapi's in-tree `upload-aws-s3` provider, if you are on a fairly recent version of strapi. 10 | 11 | ## Configuration 12 | 13 | This extends the original configurability of the provider by adding both a 14 | `baseUrl`, which may be your CDN URL, which replaces the endpoint returned from 15 | AWS with a custom URL, and `prefix`, which does exactly that: prefixes the 16 | object's path such that we do not strictly upload into the buckets root 17 | directory. This can be used to keep the bucket organized, or using a singular bucket 18 | for multiple services. Other than that you can put e.g. CloudFront Caching in front of the 19 | bucket and only expose the CloudFront URL to e.g. save traffic costs that come from 20 | direct bucket access. 21 | 22 | Everything else follows the regular strapi-provider-upload-aws-s3 schema. 23 | 24 | Your configuration is passed down to the provider. You can see the complete list of options 25 | [here](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-s3/index.html) 26 | 27 | See the [using a 28 | provider](https://strapi.io/documentation/developer-docs/latest/development/plugins/upload.html#using-a-provider) 29 | documentation for information on installing and using a provider. And see the 30 | [environment 31 | variables](https://strapi.io/documentation/developer-docs/latest/setup-deployment-guides/configurations.html#environment-variables) 32 | for setting and using environment variables in your configs. 33 | 34 | To upload with ACLs, **make sure that the S3 user has abilities 35 | "s3:PutObjectACL" in addition to the regular "s3:PutObject" ability**. Otherwise 36 | S3 will reject the upload with "Access Denied". 37 | 38 | If you cannot provide access key and secret, but instead use other (AWS) tools 39 | to authenticate to your bucket, omit `providerOptions.accessKeyId` and 40 | `providerOptions.secretAccessKey`. For more, see 41 | . 42 | 43 | ### Example 44 | 45 | `./config/plugins.js` 46 | 47 | ```js 48 | module.exports = ({ env }) => ({ 49 | // ... 50 | upload: { 51 | provider: "aws-s3-advanced", 52 | providerOptions: { 53 | accessKeyId: env("AWS_ACCESS_KEY_ID"), 54 | secretAccessKey: env("AWS_ACCESS_SECRET"), 55 | region: env("AWS_REGION"), 56 | params: { 57 | bucket: env("AWS_BUCKET"), // or "Bucket", @aws-sdk requires capitalized properties, but the convention for this file is lowercased, but the plugin understands both 58 | acl: env("AWS_BUCKET_ACL"), // or "ACL", see above 59 | }, 60 | baseUrl: env("CDN_BASE_URL"), // e.g. "https://cdn.example.com", this is stored in strapi's database to point to the file 61 | prefix: env("BUCKET_PREFIX"), // e.g. "strapi-assets". If BUCKET_PREFIX contains leading or trailing slashes, they are removed internally to construct the URL safely 62 | }, 63 | }, 64 | // ... 65 | }); 66 | ``` 67 | 68 | If using strapi >= 4.0.0, please use the below config: 69 | 70 | `./config/plugins.js` 71 | 72 | ```js 73 | module.exports = ({ env }) => ({ 74 | // ... 75 | upload: { 76 | config: { 77 | provider: "strapi-provider-upload-aws-s3-advanced", 78 | providerOptions: { 79 | accessKeyId: env("AWS_ACCESS_KEY_ID"), 80 | secretAccessKey: env("AWS_ACCESS_SECRET"), 81 | region: env("AWS_REGION"), 82 | params: { 83 | bucket: env("AWS_BUCKET"), // or "Bucket", @aws-sdk requires capitalized properties, but the convention for this file is lowercased, but the plugin understands both 84 | acl: env("AWS_BUCKET_ACL"), // or "ACL", see above 85 | }, 86 | baseUrl: env("CDN_BASE_URL"), // e.g. "https://cdn.example.com", this is stored in strapi's database to point to the file 87 | prefix: env("BUCKET_PREFIX"), // e.g. "strapi-assets". If BUCKET_PREFIX contains leading or trailing slashes, they are removed internally to construct the URL safely 88 | }, 89 | }, 90 | }, 91 | // ... 92 | }); 93 | ``` 94 | 95 | If you need to extend the configuration of the S3 client with additional 96 | properties, put them into `providerOptions.params`. The `params` object is 97 | spread into the S3 configuration at initialization, so it will accept any 98 | additional configuration this way. 99 | 100 | > Note: If you are migrating from a pre-4.0.0 version (i.e. v3.6.8 or earlier), 101 | > the `files` relation will include `aws-s3-advanced` as the provider. 102 | > Previously, the prefix "strapi-upload-provider" was assumed to always be 103 | > present for upload provider plugins. _This is no longer the case in >= 4.0.0_, 104 | > hence when uploading with the newer version of this provider, strapi will 105 | > insert new files with the full provider package name, i.e., 106 | > `strapi-provider-upload-aws-s3-advanced`. See [Migration](#migration) for 107 | > details on the required manual work. 108 | 109 | #### Image Previews 110 | 111 | To allow the thumbnails to properly populate, add the below config to 112 | 113 | `./config/middlewares.js` 114 | 115 | ```js 116 | module.exports = ({ env }) => [ 117 | // ... 118 | { 119 | name: "strapi::security", 120 | config: { 121 | contentSecurityPolicy: { 122 | useDefaults: true, 123 | directives: { 124 | "connect-src": ["'self'", "https:"], 125 | "img-src": ["'self'", "data:", "blob:", `${env("CDN_BASE_URL")}`], 126 | "media-src": ["'self'", "data:", "blob:", `${env("CDN_BASE_URL")}`], 127 | upgradeInsecureRequests: null, 128 | }, 129 | }, 130 | }, 131 | }, 132 | // ... 133 | ]; 134 | ``` 135 | 136 | ## Migration 137 | 138 | ### v5.0.0 139 | 140 | You don't need to do anything on your end except updating the dependencies. 141 | 142 | v5.0.0 rewrites the entire provider in Typescript and introduces Unit Tests, 143 | among deprecating NodeJS versions lower than v14. Specifically the last aspect 144 | requires a new major version. Other than that, nothing changed in terms of 145 | configuration surface. 146 | 147 | ### v4.1.x 148 | 149 | To allow for an empty `baseUrl` (#9), we made some adjustments to the way the 150 | configuration is parsed: in your `plugins.js`, `env("CDN_BASE_URL")` uses 151 | strapi's helper function for parsing ENV variables. If any second argument is 152 | omitted, `undefined` is returned. Thus, if your ENV does not contain any value 153 | for `CDN_BASE_URL`, you are good to go. Undefined `baseUrl` causes the plugin to 154 | prepend the cannonic default endpoint of your storage provider, e.g., 155 | `https://mystoragebucket.s3.amazonaws.com`. 156 | 157 | **If instead you defined `CDN_BASE_URL` to be `""`, the `env` helper returns 158 | that empty string.** Previously, this was treated as the same case as using 159 | `undefined`. In some scenarios however you might not want this, e.g., local 160 | development. **Thus, we now check explicitly for undefinedness instead of the 161 | prior truthiness.**. If you defined `CDN_BASE_URL` to be an empty string and 162 | relied upon the prepending of the cannonical default endpoint, change your ENV 163 | variable either explicitly to the endpoint's URL **or** make it undefined. 164 | 165 | ### v3.x to v4.0.x 166 | 167 | Strapi now uses the full package name as provider name, as seen in the 168 | configuration of the provider in the Example section above. This means that the 169 | relation will include different provider names when using the newer version of 170 | this provider with strapi >= 4.0.0 on data from pre-4.0.0. In particular, you 171 | will find that the pre-4.0.0 `files` will have the provider `aws-s3-advanved`, 172 | while the newer ones will have `strapi-provider-aws-s3-advanved`. **If you're 173 | not going to change the existing files in your CDN, you will not need to take 174 | any actions**. The provider attribute is only used for mapping the handler for 175 | creating or deleting files to the handlers defined in _this_ provider. Files 176 | will remain readable with the old provider and new files will be added with the 177 | new provider name. **Only if you want to delete old files from the new provider, 178 | you will be required to adapt the `files` table**. 179 | 180 | In strapi >= 4.0.0, only SQL databases are officially supported, so we will only 181 | provide queries for the supported backends: 182 | 183 | #### PostgreSQL 184 | 185 | ```sql 186 | UPDATE files SET provider = 'strapi-provider-upload-aws-s3-advanced' WHERE provider = 'aws-s3-advanced'; 187 | ``` 188 | 189 | #### MySQL 190 | 191 | ```sql 192 | UPDATE `files` SET `provider` = `strapi-provider-upload-aws-s3-advanced` WHERE `provider` = `aws-s3-advanced`; 193 | ``` 194 | 195 | #### SQLite 196 | 197 | ```sql 198 | UPDATE files SET provider = 'strapi-provider-upload-aws-s3-advanced' WHERE provider = 'aws-s3-advanced'; 199 | ``` 200 | 201 | ## Resources 202 | 203 | - [License](LICENSE) 204 | 205 | ## Links 206 | 207 | - [Strapi website](https://strapi.io/) 208 | - [Strapi community on Slack](https://slack.strapi.io) 209 | - [Strapi news on Twitter](https://twitter.com/strapijs) 210 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "strapi-provider-upload-aws-s3-advanced", 3 | "version": "5.0.1", 4 | "description": "AWS S3 provider for strapi upload with more advanced configuration options", 5 | "homepage": "https://strapi.io", 6 | "keywords": [ 7 | "upload", 8 | "aws", 9 | "s3", 10 | "strapi" 11 | ], 12 | "directories": { 13 | "lib": "./lib" 14 | }, 15 | "main": "./lib", 16 | "dependencies": { 17 | "@aws-sdk/client-s3": "^3.43.0", 18 | "@aws-sdk/lib-storage": "^3.100.0" 19 | }, 20 | "devDependencies": { 21 | "@types/jest": "^29.2.4", 22 | "@types/node": "^18.11.11", 23 | "aws-sdk-client-mock": "^2.0.1", 24 | "aws-sdk-client-mock-jest": "^2.0.1", 25 | "jest": "^29.3.1", 26 | "ts-jest": "^29.0.3", 27 | "typescript": "^4.9.3" 28 | }, 29 | "strapi": { 30 | "isProvider": true 31 | }, 32 | "author": { 33 | "name": "Alexander Bartolomey", 34 | "email": "occloxium@gmail.com" 35 | }, 36 | "maintainers": [ 37 | { 38 | "name": "Alexander Bartolomey", 39 | "email": "occloxium@gmail.com" 40 | } 41 | ], 42 | "repository": { 43 | "type": "git", 44 | "url": "git://github.com/zoomoid/strapi-provider-upload-aws-s3-advanced.git" 45 | }, 46 | "bugs": { 47 | "url": "https://github.com/zoomoid/strapi-provider-upload-aws-s3-advanced/issues" 48 | }, 49 | "engines": { 50 | "node": ">=14.19.1 <=18.x.x", 51 | "npm": ">=6.0.0" 52 | }, 53 | "license": "MIT", 54 | "scripts": { 55 | "test": "jest --no-cache", 56 | "build": "tsc", 57 | "prepack": "tsc" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/__tests__/provider.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DeleteObjectCommand, 3 | PutObjectCommand, S3Client 4 | } from "@aws-sdk/client-s3"; 5 | import { mockClient } from "aws-sdk-client-mock"; 6 | import "aws-sdk-client-mock-jest"; 7 | import { Readable } from "stream"; 8 | import { init, type File } from ".."; 9 | 10 | const client = new S3Client({ 11 | region: "eu-central-1" 12 | }) 13 | const s3ClientMock = mockClient(client); 14 | 15 | describe("aws-s3-advanced provider", () => { 16 | const providerInstance = init({ 17 | params: { 18 | Bucket: "test-bucket", 19 | }, 20 | region: "eu-central-1", 21 | client: client, 22 | }); 23 | 24 | beforeEach(() => { 25 | s3ClientMock.reset(); 26 | }); 27 | 28 | it("should upload a buffer to s3", async () => { 29 | s3ClientMock.on(PutObjectCommand).resolves({}); 30 | 31 | // this buffer is below @aws-sdk/lib-storage.MIN_PART_SIZE = 1024 * 1024 * 5 (5Mb) 32 | // so it results in a single PutObjectCommand 33 | const buffer = Buffer.from("Test Text from Buffer", "utf-8"); 34 | 35 | const file: File = { 36 | buffer: buffer, 37 | ext: ".txt", 38 | mime: "text/plain", 39 | hash: "12345", 40 | path: "", 41 | }; 42 | 43 | await providerInstance.upload(file); 44 | 45 | expect(file.url).toBeDefined(); 46 | expect(s3ClientMock).toHaveReceivedCommand(PutObjectCommand); 47 | }); 48 | 49 | it("should upload a readable stream to s3", async () => { 50 | s3ClientMock.on(PutObjectCommand).resolves({}); 51 | 52 | // this stream is below @aws-sdk/lib-storage.MIN_PART_SIZE = 1024 * 1024 * 5 (5Mb) 53 | // so it results in a single PutObjectCommand 54 | const stream = Readable.from("Test Text for Stream usage", { 55 | encoding: "utf-8", 56 | }); 57 | 58 | const file: File = { 59 | stream: stream, 60 | ext: ".txt", 61 | mime: "text/plain", 62 | hash: "demo-text-from-stream_12345", 63 | path: "", 64 | }; 65 | 66 | await providerInstance.upload(file); 67 | 68 | expect(file.url).toBeDefined(); 69 | expect(s3ClientMock).toHaveReceivedCommand(PutObjectCommand); 70 | }); 71 | 72 | it("should delete an object from s3", async () => { 73 | s3ClientMock.on(DeleteObjectCommand).resolves({}); 74 | 75 | const file: File = { 76 | ext: "txt", 77 | mime: "text/plain", 78 | hash: "12345", 79 | path: "demo-text-from-stream", 80 | }; 81 | 82 | await providerInstance.delete(file); 83 | 84 | expect(s3ClientMock).toHaveReceivedCommand(DeleteObjectCommand); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DeleteObjectCommand, 3 | type PutObjectCommandInput, 4 | S3Client, 5 | } from "@aws-sdk/client-s3"; 6 | import { Upload } from "@aws-sdk/lib-storage"; 7 | import { Stream } from "stream"; 8 | 9 | interface Config { 10 | accessKeyId?: string; 11 | secretAccessKey?: string; 12 | region: string; 13 | prefix?: string; 14 | baseUrl?: string; 15 | params: Partial<{ 16 | Bucket: string; 17 | bucket: string; 18 | ACL: string; 19 | acl: string; 20 | }> & 21 | Record; 22 | client?: any, // allows to pass in an instantiated S3 client into init. Useful for unit testing 23 | } 24 | 25 | export interface File { 26 | stream?: Stream; 27 | buffer?: any; 28 | mime?: string; 29 | ext?: string; 30 | /** hash contains the entire filename, expect for the extension */ 31 | hash?: string; 32 | /** path seems to almost be empty */ 33 | path?: string; 34 | /** the S3 object URL */ 35 | url?: string; 36 | } 37 | 38 | /** 39 | * Removes leading and trailing slashes from a path prefix and returns either no prefix ("") 40 | * or a prefix without a leading but with a trailing slash 41 | * @param prefix bucket prefix to use for putting objects into S3's folder abstraction 42 | * @returns normalized prefix string 43 | */ 44 | function normalizePrefix(prefix: string): string { 45 | prefix = prefix.trim().replace(/^\/*/, "").replace(/\/*$/, ""); 46 | if (!prefix) { 47 | return ""; 48 | } 49 | return prefix + "/"; 50 | } 51 | 52 | /** 53 | * Safely joins a list of path segments, similar to how Node's path library's "join" does 54 | * @param segments path segments 55 | * @returns single path string joined by forward slashes 56 | */ 57 | function join(...segments: string[]): string { 58 | let s = ""; 59 | for (let i = 0; i < segments.length - 1; i++) { 60 | const l = segments[i]; 61 | s += l.endsWith("/") || l == "" ? l : l + "/"; 62 | } 63 | s += segments[segments.length - 1]; 64 | return s; 65 | } 66 | 67 | /** 68 | * Initialize the plugin by bootstrapping an S3 client from the config 69 | * @param config Strapi provider plugin configuration. Apart from the required 70 | * @returns Provider object containing handlers for upload, uploadStream, and delete actions 71 | */ 72 | export function init({ 73 | region, 74 | accessKeyId, 75 | secretAccessKey, 76 | baseUrl, 77 | client, 78 | ...config 79 | }: Config & Record) { 80 | let S3: S3Client 81 | if(!client) { 82 | // instantiate fresh S3 client, this should be the default at runtime 83 | const credentials = (() => { 84 | if (accessKeyId && secretAccessKey) { 85 | return { 86 | credentials: { 87 | accessKeyId: accessKeyId, 88 | secretAccessKey: secretAccessKey, 89 | }, 90 | }; 91 | } 92 | return {}; 93 | })(); 94 | 95 | S3 = new S3Client({ 96 | ...credentials, 97 | ...config, 98 | region, 99 | }); 100 | } else { 101 | S3 = client 102 | } 103 | 104 | const prefix = config.prefix ? normalizePrefix(config.prefix) : ""; 105 | const bucket = config.params.Bucket || config.params.bucket; 106 | const acl = (() => { 107 | if (config.params.ACL) { 108 | return { ACL: config.params.ACL }; 109 | } 110 | if (config.params.acl) { 111 | return { ACL: config.params.acl }; 112 | } 113 | return {}; 114 | })(); 115 | 116 | /** 117 | * Uploads a buffered or streamed file to S3 using the previously configured client 118 | * @param file File object from strapi controller 119 | * @param customParams action parameters, overridable from config, see https://github.com/strapi/strapi/tree/main/packages/providers/upload-aws-s3 120 | */ 121 | const upload = async ( 122 | file: File, 123 | customParams: Record = {} 124 | ) => { 125 | const path = file.path ?? ""; 126 | const filename = `${file.hash}${file.ext}`; 127 | const objectPath = join(prefix, path, filename); 128 | 129 | const uploadParams: PutObjectCommandInput = { 130 | Bucket: bucket, 131 | Key: objectPath, 132 | Body: file.stream || Buffer.from(file.buffer, "binary"), 133 | ContentType: file.mime, 134 | ...acl, 135 | ...customParams, 136 | }; 137 | try { 138 | const uploadPromise = new Upload({ 139 | client: S3, 140 | params: uploadParams, 141 | }); 142 | await uploadPromise.done(); 143 | if (baseUrl === undefined) { 144 | // assemble virtual-host-based S3 endpoint 145 | const hostname = [bucket, "s3", region, "amazonaws", "com"].join("."); 146 | file.url = `https://${hostname}/${objectPath}`; 147 | } else { 148 | file.url = join(baseUrl ?? "", objectPath); 149 | } 150 | } catch (err) { 151 | console.error("Error uploading object to bucket %s", objectPath, err); 152 | throw err; 153 | } 154 | }; 155 | 156 | return { 157 | uploadStream(file: File, customParams: Record = {}) { 158 | return upload(file, customParams); 159 | }, 160 | upload(file: File, customParams: Record = {}) { 161 | return upload(file, customParams); 162 | }, 163 | /** 164 | * Deletes an object from the configured bucket 165 | * @param file File object from strapi controller 166 | * @param customParams action parameters, overridable from config, see https://github.com/strapi/strapi/tree/main/packages/providers/upload-aws-s3 167 | */ 168 | async delete(file: File, customParams: Record = {}) { 169 | const path = file.path ?? ""; 170 | const filename = `${file.hash}${file.ext}`; 171 | const objectPath = join(prefix, path, filename); 172 | try { 173 | await S3.send( 174 | new DeleteObjectCommand({ 175 | Bucket: bucket, 176 | Key: objectPath, 177 | ...customParams, 178 | }) 179 | ); 180 | } catch (err) { 181 | console.error("Error deleting object to bucket %s", objectPath, err); 182 | throw err; 183 | } 184 | }, 185 | }; 186 | } 187 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 4 | "lib": [ 5 | "ES2022" 6 | ], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 7 | "module": "CommonJS" /* Specify what module code is generated. */, 8 | "rootDir": "./src" /* Specify the root folder within your source files. */, 9 | "outDir": "./lib" /* Specify an output folder for all emitted files. */, 10 | "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 11 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 12 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 13 | "strict": true /* Enable all strict type-checking options. */, 14 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 15 | }, 16 | "include": ["src"], 17 | "exclude": ["node_modules", "spec/**", "lib/**", "**/__tests__/*"] 18 | } 19 | --------------------------------------------------------------------------------