Merge branch 'main' into patch-1

This commit is contained in:
DMehaffy 2023-03-28 04:25:51 -07:00 committed by GitHub
commit 58c6e40d2b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
65 changed files with 34777 additions and 24416 deletions

5
.gitattributes vendored
View File

@ -104,3 +104,8 @@ AUTHORS text
*.woff binary
*.pyc binary
*.pdf binary
# yarn
/.yarn/** linguist-vendored
/.yarn/releases/* binary
/.yarn/plugins/**/* binary

File diff suppressed because one or more lines are too long

View File

@ -1,180 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@actions/core@1.6.0":
version "1.6.0"
resolved "https://registry.yarnpkg.com/@actions/core/-/core-1.6.0.tgz#0568e47039bfb6a9170393a73f3b7eb3b22462cb"
integrity sha512-NB1UAZomZlCV/LmJqkLhNTqtKfFXJZAUPcfl/zqG7EfsQdeUJtaWO98SGbuQ3pydJ3fHl2CvI/51OKYlCYYcaw==
dependencies:
"@actions/http-client" "^1.0.11"
"@actions/github@5.0.0":
version "5.0.0"
resolved "https://registry.yarnpkg.com/@actions/github/-/github-5.0.0.tgz#1754127976c50bd88b2e905f10d204d76d1472f8"
integrity sha512-QvE9eAAfEsS+yOOk0cylLBIO/d6WyWIOvsxxzdrPFaud39G6BOkUwScXZn1iBzQzHyu9SBkkLSWlohDWdsasAQ==
dependencies:
"@actions/http-client" "^1.0.11"
"@octokit/core" "^3.4.0"
"@octokit/plugin-paginate-rest" "^2.13.3"
"@octokit/plugin-rest-endpoint-methods" "^5.1.1"
"@actions/http-client@^1.0.11":
version "1.0.11"
resolved "https://registry.yarnpkg.com/@actions/http-client/-/http-client-1.0.11.tgz#c58b12e9aa8b159ee39e7dd6cbd0e91d905633c0"
integrity sha512-VRYHGQV1rqnROJqdMvGUbY/Kn8vriQe/F9HR2AlYHzmKuM/p3kjNuXhmdBfcVgsvRWTz5C5XW5xvndZrVBuAYg==
dependencies:
tunnel "0.0.6"
"@octokit/auth-token@^2.4.4":
version "2.5.0"
resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-2.5.0.tgz#27c37ea26c205f28443402477ffd261311f21e36"
integrity sha512-r5FVUJCOLl19AxiuZD2VRZ/ORjp/4IN98Of6YJoJOkY75CIBuYfmiNHGrDwXr+aLGG55igl9QrxX3hbiXlLb+g==
dependencies:
"@octokit/types" "^6.0.3"
"@octokit/core@^3.4.0":
version "3.5.1"
resolved "https://registry.yarnpkg.com/@octokit/core/-/core-3.5.1.tgz#8601ceeb1ec0e1b1b8217b960a413ed8e947809b"
integrity sha512-omncwpLVxMP+GLpLPgeGJBF6IWJFjXDS5flY5VbppePYX9XehevbDykRH9PdCdvqt9TS5AOTiDide7h0qrkHjw==
dependencies:
"@octokit/auth-token" "^2.4.4"
"@octokit/graphql" "^4.5.8"
"@octokit/request" "^5.6.0"
"@octokit/request-error" "^2.0.5"
"@octokit/types" "^6.0.3"
before-after-hook "^2.2.0"
universal-user-agent "^6.0.0"
"@octokit/endpoint@^6.0.1":
version "6.0.12"
resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-6.0.12.tgz#3b4d47a4b0e79b1027fb8d75d4221928b2d05658"
integrity sha512-lF3puPwkQWGfkMClXb4k/eUT/nZKQfxinRWJrdZaJO85Dqwo/G0yOC434Jr2ojwafWJMYqFGFa5ms4jJUgujdA==
dependencies:
"@octokit/types" "^6.0.3"
is-plain-object "^5.0.0"
universal-user-agent "^6.0.0"
"@octokit/graphql@^4.5.8":
version "4.8.0"
resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-4.8.0.tgz#664d9b11c0e12112cbf78e10f49a05959aa22cc3"
integrity sha512-0gv+qLSBLKF0z8TKaSKTsS39scVKF9dbMxJpj3U0vC7wjNWFuIpL/z76Qe2fiuCbDRcJSavkXsVtMS6/dtQQsg==
dependencies:
"@octokit/request" "^5.6.0"
"@octokit/types" "^6.0.3"
universal-user-agent "^6.0.0"
"@octokit/openapi-types@^11.2.0":
version "11.2.0"
resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-11.2.0.tgz#b38d7fc3736d52a1e96b230c1ccd4a58a2f400a6"
integrity sha512-PBsVO+15KSlGmiI8QAzaqvsNlZlrDlyAJYcrXBCvVUxCp7VnXjkwPoFHgjEJXx3WF9BAwkA6nfCUA7i9sODzKA==
"@octokit/plugin-paginate-rest@^2.13.3":
version "2.17.0"
resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.17.0.tgz#32e9c7cab2a374421d3d0de239102287d791bce7"
integrity sha512-tzMbrbnam2Mt4AhuyCHvpRkS0oZ5MvwwcQPYGtMv4tUa5kkzG58SVB0fcsLulOZQeRnOgdkZWkRUiyBlh0Bkyw==
dependencies:
"@octokit/types" "^6.34.0"
"@octokit/plugin-rest-endpoint-methods@^5.1.1":
version "5.13.0"
resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-5.13.0.tgz#8c46109021a3412233f6f50d28786f8e552427ba"
integrity sha512-uJjMTkN1KaOIgNtUPMtIXDOjx6dGYysdIFhgA52x4xSadQCz3b/zJexvITDVpANnfKPW/+E0xkOvLntqMYpviA==
dependencies:
"@octokit/types" "^6.34.0"
deprecation "^2.3.1"
"@octokit/request-error@^2.0.5", "@octokit/request-error@^2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-2.1.0.tgz#9e150357831bfc788d13a4fd4b1913d60c74d677"
integrity sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==
dependencies:
"@octokit/types" "^6.0.3"
deprecation "^2.0.0"
once "^1.4.0"
"@octokit/request@^5.6.0":
version "5.6.3"
resolved "https://registry.yarnpkg.com/@octokit/request/-/request-5.6.3.tgz#19a022515a5bba965ac06c9d1334514eb50c48b0"
integrity sha512-bFJl0I1KVc9jYTe9tdGGpAMPy32dLBXXo1dS/YwSCTL/2nd9XeHsY616RE3HPXDVk+a+dBuzyz5YdlXwcDTr2A==
dependencies:
"@octokit/endpoint" "^6.0.1"
"@octokit/request-error" "^2.1.0"
"@octokit/types" "^6.16.1"
is-plain-object "^5.0.0"
node-fetch "^2.6.7"
universal-user-agent "^6.0.0"
"@octokit/types@^6.0.3", "@octokit/types@^6.16.1", "@octokit/types@^6.34.0":
version "6.34.0"
resolved "https://registry.yarnpkg.com/@octokit/types/-/types-6.34.0.tgz#c6021333334d1ecfb5d370a8798162ddf1ae8218"
integrity sha512-s1zLBjWhdEI2zwaoSgyOFoKSl109CUcVBCc7biPJ3aAf6LGLU6szDvi31JPU7bxfla2lqfhjbbg/5DdFNxOwHw==
dependencies:
"@octokit/openapi-types" "^11.2.0"
"@vercel/ncc@0.33.1":
version "0.33.1"
resolved "https://registry.yarnpkg.com/@vercel/ncc/-/ncc-0.33.1.tgz#b240080a3c1ded9446a30955a06a79851bb38f71"
integrity sha512-Mlsps/P0PLZwsCFtSol23FGqT3FhBGb4B1AuGQ52JTAtXhak+b0Fh/4T55r0/SVQPeRiX9pNItOEHwakGPmZYA==
before-after-hook@^2.2.0:
version "2.2.2"
resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.2.tgz#a6e8ca41028d90ee2c24222f201c90956091613e"
integrity sha512-3pZEU3NT5BFUo/AD5ERPWOgQOCZITni6iavr5AUw5AUwQjMlI0kzu5btnyD39AF0gUEsDPwJT+oY1ORBJijPjQ==
deprecation@^2.0.0, deprecation@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919"
integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==
is-plain-object@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344"
integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==
node-fetch@^2.6.7:
version "2.6.7"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
dependencies:
whatwg-url "^5.0.0"
once@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
dependencies:
wrappy "1"
tr46@~0.0.3:
version "0.0.3"
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=
tunnel@0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c"
integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==
universal-user-agent@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-6.0.0.tgz#3381f8503b251c0d9cd21bc1de939ec9df5480ee"
integrity sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==
webidl-conversions@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
integrity sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=
whatwg-url@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
integrity sha1-lmRU6HZUYuN2RNNib2dCzotwll0=
dependencies:
tr46 "~0.0.3"
webidl-conversions "^3.0.0"
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=

View File

@ -10,6 +10,6 @@ export JWT_SECRET="aSecret"
opts=($DB_OPTIONS)
yarn run -s build:ts
yarn run -s test:generate-app "${opts[@]}"
yarn run -s test:api --no-generate-app
yarn nx run-many --target=build:ts --nx-ignore-cycles --skip-nx-cache
yarn run test:generate-app "${opts[@]}"
yarn run test:api --no-generate-app

View File

@ -1,7 +1,5 @@
yarn global add lockfile-lint
lockfile-lint \
yarn dlx lockfile-lint \
--type $LOCKFILE_TYPE \
--path $LOCKFILE_PATH \
--allowed-hosts $LOCKFILE_ALLOWED_HOSTS \
--validate-https
--allowed-schemes "npm:" "workspace:" "patch:"

View File

@ -27,7 +27,8 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 18
cache: yarn
path: '**/node_modules'
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}
- uses: preactjs/compressed-size-action@v2
with:

View File

@ -24,5 +24,6 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
cache: yarn
path: '**/node_modules'
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}
- uses: ./.github/actions/security/lockfile

View File

@ -33,10 +33,11 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 18
cache: yarn
path: '**/node_modules'
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}
- name: Install dependencies
run: yarn install --frozen-lockfile
run: yarn install --immutable
- name: Build website
run: yarn build

25
.github/workflows/experimental.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: 'Experimental Releases'
on:
workflow_dispatch:
permissions:
contents: read # to fetch code (actions/checkout)
jobs:
publish:
name: 'Publish'
runs-on: ubuntu-latest
if: github.repository == 'strapi/strapi'
steps:
- uses: actions/checkout@v3
- name: Setup npmrc
run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > .npmrc
- uses: actions/setup-node@v3
with:
node-version: 16
- run: yarn
- run: ./scripts/pre-publish.sh --yes
env:
VERSION: '0.0.0-experimental.${{ github.sha }}'
DIST_TAG: experimental

View File

@ -3,7 +3,6 @@ name: 'Nightly Releases'
on:
schedule:
- cron: '0 0 * * 2-6'
workflow_dispatch:
permissions:
contents: read # to fetch code (actions/checkout)
@ -23,5 +22,5 @@ jobs:
- run: yarn
- run: ./scripts/pre-publish.sh --yes
env:
VERSION: '0.0.0-${{ github.sha }}'
DIST_TAG: experimental
VERSION: '0.0.0-next.${{ github.sha }}'
DIST_TAG: next

View File

@ -36,9 +36,9 @@ jobs:
path: '**/node_modules'
key: ${{ runner.os }}-${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
- uses: nrwl/nx-set-shas@v3
- run: yarn install --frozen-lockfile
- run: yarn install --immutable
- name: Run build:ts
run: yarn nx run-many --target=build:ts --nx-ignore-cycles
run: yarn nx run-many --target=build:ts --nx-ignore-cycles --skip-nx-cache
- name: Run lint
run: yarn nx affected --target=lint --parallel --nx-ignore-cycles
@ -61,9 +61,9 @@ jobs:
path: '**/node_modules'
key: ${{ runner.os }}-${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
- uses: nrwl/nx-set-shas@v3
- run: yarn install --frozen-lockfile
- run: yarn install --immutable
- name: Run build:ts
run: yarn nx run-many --target=build:ts --nx-ignore-cycles
run: yarn nx run-many --target=build:ts --nx-ignore-cycles --skip-nx-cache
- name: Run tests
run: yarn nx affected --target=test:unit --nx-ignore-cycles
@ -86,7 +86,7 @@ jobs:
path: '**/node_modules'
key: ${{ runner.os }}-${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
- uses: nrwl/nx-set-shas@v3
- run: yarn install --frozen-lockfile
- run: yarn install --immutable
- name: Run test
run: yarn nx affected --target=test:front --nx-ignore-cycles
@ -106,7 +106,7 @@ jobs:
with:
path: '**/node_modules'
key: ${{ runner.os }}-${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
- run: yarn install --frozen-lockfile
- run: yarn install --immutable
- name: Build
run: yarn build --projects=@strapi/admin,@strapi/helper-plugin
@ -145,7 +145,7 @@ jobs:
with:
path: '**/node_modules'
key: ${{ runner.os }}-${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
- run: yarn install --frozen-lockfile
- run: yarn install --immutable
- uses: ./.github/actions/run-api-tests
with:
dbOptions: '--dbclient=postgres --dbhost=localhost --dbport=5432 --dbname=strapi_test --dbusername=strapi --dbpassword=strapi'
@ -183,7 +183,7 @@ jobs:
with:
path: '**/node_modules'
key: ${{ runner.os }}-${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
- run: yarn install --frozen-lockfile
- run: yarn install --immutable
- uses: ./.github/actions/run-api-tests
with:
dbOptions: '--dbclient=mysql --dbhost=localhost --dbport=3306 --dbname=strapi_test --dbusername=strapi --dbpassword=strapi'
@ -220,7 +220,7 @@ jobs:
with:
path: '**/node_modules'
key: ${{ runner.os }}-${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
- run: yarn install --frozen-lockfile
- run: yarn install --immutable
- uses: ./.github/actions/run-api-tests
with:
dbOptions: '--dbclient=mysql --dbhost=localhost --dbport=3306 --dbname=strapi_test --dbusername=strapi --dbpassword=strapi'
@ -242,7 +242,7 @@ jobs:
with:
path: '**/node_modules'
key: ${{ runner.os }}-${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
- run: yarn install --frozen-lockfile
- run: yarn install --immutable
- uses: ./.github/actions/run-api-tests
env:
SQLITE_PKG: ${{ matrix.sqlite_pkg }}
@ -288,7 +288,7 @@ jobs:
with:
path: '**/node_modules'
key: ${{ runner.os }}-${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
- run: yarn install --frozen-lockfile
- run: yarn install --immutable
- uses: ./.github/actions/run-api-tests
with:
dbOptions: '--dbclient=postgres --dbhost=localhost --dbport=5432 --dbname=strapi_test --dbusername=strapi --dbpassword=strapi'
@ -330,7 +330,7 @@ jobs:
with:
path: '**/node_modules'
key: ${{ runner.os }}-${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
- run: yarn install --frozen-lockfile
- run: yarn install --immutable
- uses: ./.github/actions/run-api-tests
with:
dbOptions: '--dbclient=mysql --dbhost=localhost --dbport=3306 --dbname=strapi_test --dbusername=strapi --dbpassword=strapi'
@ -356,7 +356,7 @@ jobs:
with:
path: '**/node_modules'
key: ${{ runner.os }}-${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
- run: yarn install --frozen-lockfile
- run: yarn install --immutable
- uses: ./.github/actions/run-api-tests
env:
SQLITE_PKG: ${{ matrix.sqlite_pkg }}

12
.gitignore vendored
View File

@ -137,5 +137,15 @@ schema.graphql
.vscode/
!.vscode/settings.json
front-workspace.code-workspace
.yarn
.yarnrc
############################
# Yarn
############################
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

File diff suppressed because one or more lines are too long

873
.yarn/releases/yarn-3.5.0.cjs vendored Executable file

File diff suppressed because one or more lines are too long

11
.yarnrc.yml Normal file
View File

@ -0,0 +1,11 @@
nodeLinker: node-modules
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
spec: '@yarnpkg/plugin-interactive-tools'
defaultSemverRangePrefix: ''
preferInteractive: true
yarnPath: .yarn/releases/yarn-3.5.0.cjs

View File

@ -0,0 +1,28 @@
---
title: Introduction
slug: /upload
tags:
- upload
---
# Provider
Extends the upload plugin, to connect to different external services or applications, such as Amazon S3 buckets, Cloudinary, etc.
**In the case of the Upload plugin, the provider should be able to upload files to a remote server and delete them.**
# Using a provider
To use a provider, you need to install it and configure it in the `./config/plugins.js` file.
More info about installing providers on [strapi docs](https://docs.strapi.io/developer-docs/latest/development/providers.html#installing-providers).
# Provider development
To create a provider, you need to create a package that exports a function that returns an object with the following methods:
- `isPrivate()` (optional) - Returns a boolean indicating if the provider is private or not. If it is, the `getSignedUrl` method will be used to get the URL of the file. (default: `false`)
- `getSignedUrl(file)`. (optional) - Returns a signed URL to access the file if it requires authentication
- `upload(file)` - Uploads a file to the provider
- `uploadStream(stream)` (optional) - Uploads a stream to the provider
- `delete(file)` - Deletes a file from the provider

View File

@ -0,0 +1,17 @@
---
title: Introduction
slug: /upload
tags:
- upload
---
# Content Manager
This section is an overview of all the features related to the Upload core plugin:
```mdx-code-block
import DocCardList from '@theme/DocCardList';
import { useCurrentSidebarCategory } from '@docusaurus/theme-common';
<DocCardList items={useCurrentSidebarCategory().items} />
```

View File

@ -1,9 +1,6 @@
{
"version": "4.8.2",
"packages": [
"packages/*",
"examples/*"
],
"packages": ["packages/*", "examples/*"],
"npmClient": "yarn",
"useWorkspaces": true,
"useNx": true

View File

@ -40,9 +40,9 @@
"lint:css": "stylelint packages/**/admin/src/**/*.js",
"lint:fix": "nx run-many --target=lint --nx-ignore-cycles -- --fix",
"lint:other": "npm run prettier:other -- --check",
"format": "npm-run-all -p format:*",
"format:code": "npm run prettier:code -- --write",
"format:other": "npm run prettier:other -- --write",
"format": "yarn format:code && yarn format:other",
"format:code": "yarn prettier:code --write",
"format:other": "yarn prettier:other --write",
"prettier:code": "prettier --cache --cache-strategy content \"**/*.{js,ts}\"",
"prettier:other": "prettier --cache --cache-strategy content \"**/*.{md,css,scss,yaml,yml}\"",
"test:front": "cross-env IS_EE=true nx run-many --target=test:front --nx-ignore-cycles",
@ -87,7 +87,6 @@
"lerna": "6.5.1",
"lint-staged": "13.0.3",
"lodash": "4.17.21",
"npm-run-all": "4.1.5",
"nx": "15.8.3",
"plop": "2.7.6",
"prettier": "2.8.4",
@ -106,5 +105,6 @@
"engines": {
"node": ">=14.19.1 <=18.x.x",
"npm": ">=6.0.0"
}
},
"packageManager": "yarn@3.5.0"
}

View File

@ -36,9 +36,7 @@
}
],
"main": "./index.js",
"bin": {
"create-strapi-app": "./index.js"
},
"bin": "./index.js",
"scripts": {
"lint": "eslint ."
},

View File

@ -31,9 +31,7 @@
}
],
"main": "./index.js",
"bin": {
"create-strapi-starter": "./index.js"
},
"bin": "./index.js",
"scripts": {
"lint": "eslint ."
},

View File

@ -0,0 +1,7 @@
export default function useLocalesProvider() {
return {
changeLocale() {},
localeNames: { en: 'English' },
messages: ['test'],
};
}

View File

@ -0,0 +1,7 @@
export default function () {
return {
logos: {
auth: { custom: 'customAuthLogo.png', default: 'defaultAuthLogo.png' },
},
};
}

View File

@ -1,7 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import styled from 'styled-components';
import get from 'lodash/get';
import omit from 'lodash/omit';
import { useHistory } from 'react-router-dom';
import PropTypes from 'prop-types';
@ -14,6 +13,7 @@ import {
useTracking,
getYupInnerErrors,
Link,
useAPIErrorHandler,
} from '@strapi/helper-plugin';
import {
Box,
@ -27,17 +27,10 @@ import {
Typography,
} from '@strapi/design-system';
import { EyeStriked, Eye } from '@strapi/icons';
import UnauthenticatedLayout, {
Column,
LayoutContent,
} from '../../../../layouts/UnauthenticatedLayout';
import UnauthenticatedLayout, { LayoutContent } from '../../../../layouts/UnauthenticatedLayout';
import Logo from '../../../../components/UnauthenticatedLogo';
import FieldActionWrapper from '../FieldActionWrapper';
const CenteredBox = styled(Box)`
text-align: center;
`;
const A = styled.a`
color: ${({ theme }) => theme.colors.primary600};
`;
@ -58,6 +51,8 @@ const Register = ({ authType, fieldsToDisable, noSignin, onSubmit, schema }) =>
const { trackUsage } = useTracking();
const { formatMessage } = useIntl();
const query = useQuery();
const { formatAPIError } = useAPIErrorHandler();
const registrationToken = query.get('registrationToken');
useEffect(() => {
@ -73,17 +68,17 @@ const Register = ({ authType, fieldsToDisable, noSignin, onSubmit, schema }) =>
if (data) {
setUserInfo(data);
}
} catch (err) {
const errorMessage = get(err, ['response', 'data', 'message'], 'An error occurred');
} catch (error) {
const message = formatAPIError(error);
toggleNotification({
type: 'warning',
message: errorMessage,
message,
});
// Redirect to the oops page in case of an invalid token
// @alexandrebodin @JAB I am not sure it is the wanted behavior
push(`/auth/oops?info=${encodeURIComponent(errorMessage)}`);
push(`/auth/oops?info=${encodeURIComponent(message)}`);
}
};
@ -92,6 +87,20 @@ const Register = ({ authType, fieldsToDisable, noSignin, onSubmit, schema }) =>
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [registrationToken]);
function normalizeData(data) {
return Object.entries(data).reduce((acc, [key, value]) => {
let normalizedvalue = value;
if (!['password', 'confirmPassword'].includes(key) && typeof value === 'string') {
normalizedvalue = normalizedvalue.trim();
}
acc[key] = normalizedvalue;
return acc;
}, {});
}
return (
<UnauthenticatedLayout>
<LayoutContent>
@ -107,8 +116,10 @@ const Register = ({ authType, fieldsToDisable, noSignin, onSubmit, schema }) =>
news: false,
}}
onSubmit={async (data, formik) => {
const normalizedData = normalizeData(data);
try {
await schema.validate(data, { abortEarly: false });
await schema.validate(normalizedData, { abortEarly: false });
if (submitCount > 0 && authType === 'register-admin') {
trackUsage('didSubmitWithErrorsFirstAdmin', { count: submitCount.toString() });
@ -117,11 +128,11 @@ const Register = ({ authType, fieldsToDisable, noSignin, onSubmit, schema }) =>
if (registrationToken) {
// We need to pass the registration token in the url param to the api in order to submit another admin user
onSubmit(
{ userInfo: omit(data, ['registrationToken']), registrationToken },
{ userInfo: omit(normalizedData, ['registrationToken']), registrationToken },
formik
);
} else {
onSubmit(data, formik);
onSubmit(normalizedData, formik);
}
} catch (err) {
const errors = getYupInnerErrors(err);
@ -138,27 +149,26 @@ const Register = ({ authType, fieldsToDisable, noSignin, onSubmit, schema }) =>
return (
<Form noValidate>
<Main>
<Column>
<Flex direction="column" alignItems="stretch" gap={3}>
<Logo />
<Box paddingTop={6} paddingBottom={1}>
<Typography as="h1" variant="alpha">
{formatMessage({
id: 'Auth.form.welcome.title',
defaultMessage: 'Welcome to Strapi!',
})}
</Typography>
</Box>
<CenteredBox paddingBottom={7}>
<Typography variant="epsilon" textColor="neutral600">
{formatMessage({
id: 'Auth.form.register.subtitle',
defaultMessage:
'Credentials are only used to authenticate in Strapi. All saved data will be stored in your database.',
})}
</Typography>
</CenteredBox>
</Column>
<Flex direction="column" alignItems="stretch" gap={6}>
<Typography as="h1" variant="alpha" textAlign="center">
{formatMessage({
id: 'Auth.form.welcome.title',
defaultMessage: 'Welcome to Strapi!',
})}
</Typography>
<Typography variant="epsilon" textColor="neutral600" textAlign="center">
{formatMessage({
id: 'Auth.form.register.subtitle',
defaultMessage:
'Credentials are only used to authenticate in Strapi. All saved data will be stored in your database.',
})}
</Typography>
</Flex>
<Flex direction="column" alignItems="stretch" gap={6} marginTop={7}>
<Grid gap={4}>
<GridItem col={6}>
<TextInput
@ -204,7 +214,6 @@ const Register = ({ authType, fieldsToDisable, noSignin, onSubmit, schema }) =>
value={values.password}
error={errors.password ? formatMessage(errors.password) : undefined}
endAction={
// eslint-disable-next-line react/jsx-wrap-multilines
<FieldActionWrapper
onClick={(e) => {
e.preventDefault();
@ -245,7 +254,6 @@ const Register = ({ authType, fieldsToDisable, noSignin, onSubmit, schema }) =>
errors.confirmPassword ? formatMessage(errors.confirmPassword) : undefined
}
endAction={
// eslint-disable-next-line react/jsx-wrap-multilines
<FieldActionWrapper
onClick={(e) => {
e.preventDefault();

View File

@ -2,6 +2,7 @@
const { assoc, has, prop, omit } = require('lodash/fp');
const strapiUtils = require('@strapi/utils');
const { mapAsync } = require('@strapi/utils');
const { ApplicationError } = require('@strapi/utils').errors;
const { getDeepPopulate, getDeepPopulateDraftCount } = require('./utils/populate');
const { getDeepRelationsCount } = require('./utils/count');
@ -72,40 +73,77 @@ module.exports = ({ strapi }) => ({
return assoc(`${CREATED_BY_ATTRIBUTE}.roles`, roles, entity);
},
find(opts, uid) {
/**
* Extend this function from other plugins to add custom mapping of entity
* responses
* @param {Object} entity
* @returns
*/
mapEntity(entity) {
return entity;
},
/**
* Some entity manager functions may return multiple entities or one entity.
* This function maps the response in both cases
* @param {Array|Object|null} entities
* @param {string} uid
*/
async mapEntitiesResponse(entities, uid) {
if (entities?.results) {
const mappedResults = await mapAsync(entities.results, (entity) =>
this.mapEntity(entity, uid)
);
return { ...entities, results: mappedResults };
}
// if entity is single type
return this.mapEntity(entities, uid);
},
async find(opts, uid) {
const params = { ...opts, populate: getDeepPopulate(uid) };
return strapi.entityService.findMany(uid, params);
const entities = await strapi.entityService.findMany(uid, params);
return this.mapEntitiesResponse(entities, uid);
},
findPage(opts, uid) {
async findPage(opts, uid) {
const params = { ...opts, populate: getDeepPopulate(uid, { maxLevel: 1 }) };
return strapi.entityService.findPage(uid, params);
const entities = await strapi.entityService.findPage(uid, params);
return this.mapEntitiesResponse(entities, uid);
},
findWithRelationCountsPage(opts, uid) {
async findWithRelationCountsPage(opts, uid) {
const counterPopulate = getDeepPopulate(uid, { countMany: true, maxLevel: 1 });
const params = { ...opts, populate: addCreatedByRolesPopulate(counterPopulate) };
return strapi.entityService.findWithRelationCountsPage(uid, params);
const entities = await strapi.entityService.findWithRelationCountsPage(uid, params);
return this.mapEntitiesResponse(entities, uid);
},
findOneWithCreatorRolesAndCount(id, uid) {
async findOneWithCreatorRolesAndCount(id, uid) {
const counterPopulate = getDeepPopulate(uid, { countMany: true, countOne: true });
const params = { populate: addCreatedByRolesPopulate(counterPopulate) };
return strapi.entityService.findOne(uid, id, params);
return strapi.entityService
.findOne(uid, id, params)
.then((entity) => this.mapEntity(entity, uid));
},
async findOne(id, uid) {
const params = { populate: getDeepPopulate(uid) };
return strapi.entityService.findOne(uid, id, params);
return strapi.entityService
.findOne(uid, id, params)
.then((entity) => this.mapEntity(entity, uid));
},
async findOneWithCreatorRoles(id, uid) {
const entity = await this.findOne(id, uid);
const entity = await this.findOne(id, uid).then((entity) => this.mapEntity(entity, uid));
if (!entity) {
return entity;
@ -130,7 +168,9 @@ module.exports = ({ strapi }) => ({
: getDeepPopulate(uid, { countMany: true, countOne: true }),
};
const entity = await strapi.entityService.create(uid, params);
const entity = await strapi.entityService
.create(uid, params)
.then((entity) => this.mapEntity(entity, uid));
// If relations were populated, relations count will be returned instead of the array of relations.
if (populateRelations) {
@ -151,7 +191,9 @@ module.exports = ({ strapi }) => ({
: getDeepPopulate(uid, { countMany: true, countOne: true }),
};
const updatedEntity = await strapi.entityService.update(uid, entity.id, params);
const updatedEntity = await strapi.entityService
.update(uid, entity.id, params)
.then((entity) => this.mapEntity(entity, uid));
// If relations were populated, relations count will be returned instead of the array of relations.
if (populateRelations) {
@ -214,12 +256,14 @@ module.exports = ({ strapi }) => ({
await emitEvent(ENTRY_PUBLISH, updatedEntity, uid);
const mappedEntity = await this.mapEntity(updatedEntity, uid);
// If relations were populated, relations count will be returned instead of the array of relations.
if (isRelationsPopulateEnabled(uid)) {
return getDeepRelationsCount(updatedEntity, uid);
return getDeepRelationsCount(mappedEntity, uid);
}
return updatedEntity;
return mappedEntity;
},
async unpublish(entity, body = {}, uid) {
@ -241,12 +285,14 @@ module.exports = ({ strapi }) => ({
await emitEvent(ENTRY_UNPUBLISH, updatedEntity, uid);
const mappedEntity = await this.mapEntity(updatedEntity, uid);
// If relations were populated, relations count will be returned instead of the array of relations.
if (isRelationsPopulateEnabled(uid)) {
return getDeepRelationsCount(updatedEntity, uid);
return getDeepRelationsCount(mappedEntity, uid);
}
return updatedEntity;
return mappedEntity;
},
async getNumberOfDraftRelations(id, uid) {

View File

@ -62,7 +62,7 @@ describe('Remote Strapi Destination', () => {
// ignore ws connection error
}
expect(WebSocket).toHaveBeenCalledWith(`ws://strapi.com/admin${TRANSFER_PATH}`);
expect(WebSocket).toHaveBeenCalledWith(`ws://strapi.com/admin${TRANSFER_PATH}`, undefined);
});
test('Should use wss protocol for https urls', async () => {
@ -76,7 +76,7 @@ describe('Remote Strapi Destination', () => {
// ignore ws connection error
}
expect(WebSocket).toHaveBeenCalledWith(`wss://strapi.com/admin${TRANSFER_PATH}`);
expect(WebSocket).toHaveBeenCalledWith(`wss://strapi.com/admin${TRANSFER_PATH}`, undefined);
});
test('Should throw on invalid protocol', async () => {

View File

@ -16,6 +16,10 @@ interface ITransferTokenAuth {
token: string;
}
type WebsocketParams = ConstructorParameters<typeof WebSocket>;
type Address = WebsocketParams[0];
type Options = WebsocketParams[2];
export interface IRemoteStrapiDestinationProviderOptions
extends Pick<ILocalStrapiDestinationProviderOptions, 'restore' | 'strategy'> {
url: URL;
@ -47,31 +51,16 @@ class RemoteStrapiDestinationProvider implements IDestinationProvider {
async initTransfer(): Promise<string> {
const { strategy, restore } = this.options;
// Wait for the connection to be made to the server, then init the transfer
return new Promise<string>((resolve, reject) => {
this.ws
?.once('open', async () => {
try {
const query = this.dispatcher?.dispatchCommand({
command: 'init',
params: { options: { strategy, restore }, transfer: 'push' },
});
const res = (await query) as server.Payload<server.InitMessage>;
if (!res?.transferID) {
throw new ProviderTransferError('Init failed, invalid response from the server');
}
resolve(res.transferID);
} catch (e: unknown) {
reject(e);
}
})
.once('error', (message) => {
reject(message);
});
const query = this.dispatcher?.dispatchCommand({
command: 'init',
params: { options: { strategy, restore }, transfer: 'push' },
});
const res = (await query) as server.Payload<server.InitMessage>;
if (!res?.transferID) {
throw new ProviderTransferError('Init failed, invalid response from the server');
}
return res.transferID;
}
#startStepOnce(stage: client.TransferPushStep) {
@ -186,6 +175,25 @@ class RemoteStrapiDestinationProvider implements IDestinationProvider {
});
}
async #connectToWebsocket(address: Address, options?: Options): Promise<WebSocket> {
return new Promise((resolve, reject) => {
const server = new WebSocket(address, options);
server.once('open', () => {
resolve(server);
});
server.once('error', (err) => {
reject(
new ProviderTransferError(err.message, {
details: {
error: err.message,
},
})
);
});
});
}
async bootstrap(): Promise<void> {
const { url, auth } = this.options;
const validProtocols = ['https:', 'http:'];
@ -205,13 +213,13 @@ class RemoteStrapiDestinationProvider implements IDestinationProvider {
const wsUrl = `${wsProtocol}//${url.host}${url.pathname}${TRANSFER_PATH}`;
// No auth defined, trying public access for transfer
if (!auth) {
ws = new WebSocket(wsUrl);
ws = await this.#connectToWebsocket(wsUrl);
}
// Common token auth, this should be the main auth method
else if (auth.type === 'token') {
const headers = { Authorization: `Bearer ${auth.token}` };
ws = new WebSocket(wsUrl, { headers });
ws = await this.#connectToWebsocket(wsUrl, { headers });
}
// Invalid auth method provided

View File

@ -1,21 +1,5 @@
'use strict';
/* eslint-disable no-nested-ternary */
const chalk = require('chalk');
const codeToColor = (code) => {
return code >= 500
? chalk.red(code)
: code >= 400
? chalk.yellow(code)
: code >= 300
? chalk.cyan(code)
: code >= 200
? chalk.green(code)
: code;
};
/**
* @type {import('./').MiddlewareFactory}
*/
@ -25,6 +9,6 @@ module.exports = (_, { strapi }) => {
await next();
const delta = Math.ceil(Date.now() - start);
strapi.log.http(`${ctx.method} ${ctx.url} (${delta} ms) ${codeToColor(ctx.status)}`);
strapi.log.http(`${ctx.method} ${ctx.url} (${delta} ms) ${ctx.status}`);
};
};

View File

@ -17,6 +17,7 @@ const LIMITED_EVENTS = [
'didSaveMediaWithCaption',
'didDisableResponsiveDimensions',
'didEnableResponsiveDimensions',
'didInitializePluginUpload',
];
const createTelemetryInstance = (strapi) => {

View File

@ -66,9 +66,7 @@
],
"main": "./lib",
"types": "./lib/index.d.ts",
"bin": {
"strapi": "./bin/strapi.js"
},
"bin": "./bin/strapi.js",
"directories": {
"lib": "./lib",
"bin": "./bin",

View File

@ -7,12 +7,10 @@ import { AssetCardBase } from './AssetCardBase';
export const ImageAssetCard = ({ height, width, thumbnail, size, alt, ...props }) => {
// Prevents the browser from caching the URL for all sizes and allow react-query to make a smooth update
// instead of a full refresh
const optimizedCachingThumbnail =
width && height ? `${thumbnail}?width=${width}&height=${height}` : thumbnail;
return (
<AssetCardBase {...props} subtitle={height && width && ` - ${width}${height}`} variant="Image">
<CardAsset src={optimizedCachingThumbnail} size={size} alt={alt} />
<CardAsset src={thumbnail} size={size} alt={alt} />
</AssetCardBase>
);
};

View File

@ -480,7 +480,7 @@ describe('ImageAssetCard', () => {
alt=""
aria-hidden="true"
class="c19"
src="http://somewhere.com/hello.png?width=40&height=40"
src="http://somewhere.com/hello.png"
/>
</div>
</div>

View File

@ -390,7 +390,7 @@ exports[`MediaLibrary / AssetList snapshots the asset list 1`] = `
alt="strapi-cover_1fabc982ce.png"
aria-hidden="true"
class="c14"
src="http://localhost:1337/uploads/thumbnail_strapi_cover_1fabc982ce_5b43615ed5.png?width=1066&height=551"
src="http://localhost:1337/uploads/thumbnail_strapi_cover_1fabc982ce_5b43615ed5.png"
/>
</div>
</div>
@ -490,7 +490,7 @@ exports[`MediaLibrary / AssetList snapshots the asset list 1`] = `
>
<video
crossorigin="anonymous"
src="http://localhost:1337/uploads/mov_bbb_2f3907f7aa.mp4?updated_at=2021-09-14T07:48:30.882Z"
src="http://localhost:1337/uploads/mov_bbb_2f3907f7aa.mp4"
>
<source
type="video/mp4"

View File

@ -219,7 +219,7 @@ describe('<EditAssetDialog />', () => {
fireEvent.click(screen.getByLabelText('Download'));
expect(downloadFile).toHaveBeenCalledWith(
'http://localhost:1337/uploads/Screenshot_2_5d4a574d61.png?updated_at=2021-10-04T09:42:31.670Z',
'http://localhost:1337/uploads/Screenshot_2_5d4a574d61.png',
'Screenshot 2.png'
);
});

View File

@ -901,7 +901,7 @@ exports[`<EditAssetDialog /> renders and matches the snapshot 1`] = `
>
<img
alt="Screenshot 2.png"
src="http://localhost:1337/uploads/thumbnail_Screenshot_2_5d4a574d61.png?updated_at=2021-10-04T09:42:31.670Z"
src="http://localhost:1337/uploads/thumbnail_Screenshot_2_5d4a574d61.png"
/>
</div>
<div

View File

@ -901,7 +901,7 @@ exports[`<EditAssetDialog /> renders and matches the snapshot 1`] = `
>
<img
alt="Screenshot 2.png"
src="http://localhost:1337/uploads/thumbnail_Screenshot_2_5d4a574d61.png?updated_at=2021-10-04T09:42:31.670Z"
src="http://localhost:1337/uploads/thumbnail_Screenshot_2_5d4a574d61.png"
/>
</div>
<div

View File

@ -165,7 +165,7 @@ describe('<EditAssetDialog />', () => {
fireEvent.click(screen.getByLabelText('Download'));
expect(downloadFile).toHaveBeenCalledWith(
'http://localhost:1337/uploads/Screenshot_2_5d4a574d61.png?updated_at=2021-10-04T09:42:31.670Z',
'http://localhost:1337/uploads/Screenshot_2_5d4a574d61.png',
'Screenshot 2.png'
);
});

View File

@ -13,9 +13,8 @@ const createAssetUrl = (asset, forThumbnail = true) => {
}
const assetUrl = forThumbnail ? asset?.formats?.thumbnail?.url || asset.url : asset.url;
const backendUrl = prefixFileUrlWithBackendUrl(assetUrl);
return `${backendUrl}?updated_at=${asset.updatedAt}`;
return prefixFileUrlWithBackendUrl(assetUrl);
};
export default createAssetUrl;

View File

@ -42,6 +42,9 @@ describe('Upload plugin bootstrap function', () => {
upload: {
services: {
metrics: {
sendUploadPluginMetrics() {},
},
weeklyMetrics: {
registerCron() {},
},
},

View File

@ -32,6 +32,13 @@ jest.mock('@strapi/provider-upload-local', () => ({
},
}));
jest.mock('../utils', () => ({
...jest.requireActual('../utils'),
getService: () => ({
contentManager: { entityManager: { addSignedFileUrlsToAdmin: jest.fn() } },
}),
}));
describe('Upload plugin register function', () => {
test('The upload plugin registers the /upload route', async () => {
const registerRoute = jest.fn();

View File

@ -37,7 +37,8 @@ module.exports = async ({ strapi }) => {
await registerPermissionActions();
await getService('metrics').registerCron();
await getService('weeklyMetrics').registerCron();
getService('metrics').sendUploadPluginMetrics();
};
const registerPermissionActions = async () => {

View File

@ -1,6 +1,7 @@
'use strict';
const { merge } = require('lodash/fp');
const { mapAsync } = require('@strapi/utils');
const { getService } = require('../utils');
const { ACTIONS, FILE_MODEL_UID } = require('../constants');
const { findEntityAndCheckPermissions } = require('./utils/find-entity-and-check-permissions');
@ -26,11 +27,14 @@ module.exports = {
const pmQuery = pm.addPermissionsQueryTo(merge(defaultQuery, ctx.query));
const query = await pm.sanitizeQuery(pmQuery);
const { results, pagination } = await getService('upload').findPage(query);
const { results: files, pagination } = await getService('upload').findPage(query);
const sanitizedResults = await pm.sanitizeOutput(results);
// Sign file urls for private providers
const signedFiles = await mapAsync(files, getService('file').signFileUrls);
return { results: sanitizedResults, pagination };
const sanitizedFiles = await pm.sanitizeOutput(signedFiles);
return { results: sanitizedFiles, pagination };
},
async findOne(ctx) {
@ -46,7 +50,8 @@ module.exports = {
id
);
ctx.body = await pm.sanitizeOutput(file);
const signedFile = await getService('file').signFileUrls(file);
ctx.body = await pm.sanitizeOutput(signedFile);
},
async destroy(ctx) {

View File

@ -2,6 +2,7 @@
const _ = require('lodash');
const { ApplicationError } = require('@strapi/utils').errors;
const { mapAsync } = require('@strapi/utils');
const { getService } = require('../utils');
const { ACTIONS, FILE_MODEL_UID } = require('../constants');
const validateUploadBody = require('./validation/admin/upload');
@ -49,9 +50,12 @@ module.exports = {
}
const data = await validateUploadBody(body);
const replacedFiles = await uploadService.replace(id, { data, file: files }, { user });
const replacedFile = await uploadService.replace(id, { data, file: files }, { user });
ctx.body = await pm.sanitizeOutput(replacedFiles, { action: ACTIONS.read });
// Sign file urls for private providers
const signedFile = await getService('file').signFileUrls(replacedFile);
ctx.body = await pm.sanitizeOutput(signedFile, { action: ACTIONS.read });
},
async uploadFiles(ctx) {
@ -74,7 +78,10 @@ module.exports = {
const data = await validateUploadBody(body);
const uploadedFiles = await uploadService.upload({ data, files }, { user });
ctx.body = await pm.sanitizeOutput(uploadedFiles, { action: ACTIONS.read });
// Sign file urls for private providers
const signedFiles = await mapAsync(uploadedFiles, getService('file').signFileUrls);
ctx.body = await pm.sanitizeOutput(signedFiles, { action: ACTIONS.read });
},
async upload(ctx) {

View File

@ -6,6 +6,7 @@ const {
} = require('@strapi/utils');
const _ = require('lodash');
const registerUploadMiddleware = require('./middlewares/upload');
const { getService } = require('./utils');
/**
* Register upload plugin
@ -16,6 +17,8 @@ module.exports = async ({ strapi }) => {
await registerUploadMiddleware({ strapi });
getService('extensions').contentManager.entityManager.addSignedFileUrlsToAdmin();
if (strapi.plugin('graphql')) {
require('./graphql')({ strapi });
}
@ -83,4 +86,10 @@ const baseProvider = {
);
}
},
getSignedUrl(file) {
return file;
},
isPrivate() {
return false;
},
};

View File

@ -1,6 +1,6 @@
'use strict';
const metricsService = require('../metrics');
const metricsService = require('../metrics/weekly-metrics');
describe('metrics', () => {
describe('computeMetrics', () => {

View File

@ -0,0 +1,169 @@
'use strict';
const { signEntityMedia } = require('../entity-manager');
const { getService } = require('../../../../utils');
jest.mock('../../../../utils');
describe('Upload | extensions | entity-manager', () => {
const modelUID = 'model';
const componentUID = 'component';
const models = {
[modelUID]: {
attributes: {
media: {
type: 'media',
multiple: false,
},
media_repeatable: {
type: 'media',
multiple: true,
},
compo_media_repeatable: {
type: 'component',
repeatable: true,
component: componentUID,
},
compo_media: {
type: 'component',
component: componentUID,
},
dynamicZone: {
type: 'dynamiczone',
components: [componentUID],
},
},
},
[componentUID]: {
attributes: {
media_repeatable: {
type: 'media',
multiple: true,
},
media: {
type: 'media',
multiple: false,
},
},
},
};
const media = ['media', 'media_1'].map((entry) => ({
formats: {
thumbnail: {
url: `${entry}_thumb`,
},
large: {
url: `${entry}_large`,
},
small: {
url: `${entry}_small`,
},
medium: {
url: `${entry}_medium`,
},
},
url: `${entry}_url`,
}));
describe('signEntityMedia', () => {
let spySignFileUrls;
beforeEach(() => {
spySignFileUrls = jest.fn();
getService.mockImplementation(() => ({
signFileUrls: spySignFileUrls,
}));
global.strapi = {
plugins: {
upload: {},
},
getModel: jest.fn((uid) => models[uid]),
};
});
test('makes correct calls for media attribute', async () => {
const entity = {
media: media[0],
};
await signEntityMedia(entity, modelUID);
expect(getService).toBeCalledWith('file');
expect(spySignFileUrls).toBeCalledWith(entity.media);
});
test('makes correct calls for repeatable media', async () => {
const entity = {
media_repeatable: media,
};
await signEntityMedia(entity, modelUID);
expect(getService).toBeCalledWith('file');
expect(spySignFileUrls).toBeCalledTimes(2);
expect(spySignFileUrls).toBeCalledWith(media[0], expect.anything());
expect(spySignFileUrls).toBeCalledWith(media[1], expect.anything());
});
test('makes correct calls for components', async () => {
const entity = {
compo_media: {
media: media[0],
media_repeatable: media,
},
};
await signEntityMedia(entity, modelUID);
expect(getService).toBeCalledWith('file');
expect(spySignFileUrls).toBeCalledTimes(3);
expect(spySignFileUrls).toBeCalledWith(media[0], expect.anything());
expect(spySignFileUrls).toBeCalledWith(media[0], expect.anything());
expect(spySignFileUrls).toBeCalledWith(media[1], expect.anything());
});
test('makes correct calls for repeatable components', async () => {
const entity = {
compo_media_repeatable: [
{
media: media[0],
media_repeatable: media,
},
{
media: media[1],
media_repeatable: media,
},
],
};
await signEntityMedia(entity, modelUID);
expect(getService).toBeCalledWith('file');
expect(spySignFileUrls).toBeCalledTimes(6);
expect(spySignFileUrls).toBeCalledWith(media[0], expect.anything());
expect(spySignFileUrls).toBeCalledWith(media[1], expect.anything());
expect(spySignFileUrls).toBeCalledWith(media[0], expect.anything());
expect(spySignFileUrls).toBeCalledWith(media[1], expect.anything());
expect(spySignFileUrls).toBeCalledWith(media[0], expect.anything());
expect(spySignFileUrls).toBeCalledWith(media[1], expect.anything());
});
test('makes correct calls for dynamic zones', async () => {
const entity = {
dynamicZone: [
{
__component: componentUID,
media_repeatable: media,
media: media[1],
},
],
};
await signEntityMedia(entity, modelUID);
expect(getService).toBeCalledWith('file');
expect(spySignFileUrls).toBeCalledTimes(3);
expect(spySignFileUrls).toBeCalledWith(media[0], expect.anything());
expect(spySignFileUrls).toBeCalledWith(media[1], expect.anything());
expect(spySignFileUrls).toBeCalledWith(media[1], expect.anything());
});
});
});

View File

@ -0,0 +1,79 @@
'use strict';
const { mapAsync, traverseEntity } = require('@strapi/utils');
const { getService } = require('../../../utils');
/**
* Visitor function to sign media URLs
* @param {Object} schema
* @param {string} schema.key - The key of the attribute
* @param {string} schema.value - The value of the attribute
* @param {Object} schema.attribute - The attribute definition
* @param {Object} entry
* @param {Function} entry.set - The set function to update the value
*/
const signEntityMediaVisitor = async ({ key, value, attribute }, { set }) => {
const { signFileUrls } = getService('file');
if (!value || attribute.type !== 'media') {
return;
}
// If the attribute is repeatable sign each file
if (attribute.multiple) {
const signedFiles = await mapAsync(value, signFileUrls);
set(key, signedFiles);
return;
}
// If the attribute is not repeatable only sign a single file
const signedFile = await signFileUrls(value);
set(key, signedFile);
};
/**
*
* Iterate through an entity manager result
* Check which modelAttributes are media and pre sign the image URLs
* if they are from the current upload provider
*
* @param {Object} entity
* @param {Object} modelAttributes
* @returns
*/
const signEntityMedia = async (entity, uid) => {
const model = strapi.getModel(uid);
return traverseEntity(signEntityMediaVisitor, { schema: model }, entity);
};
const addSignedFileUrlsToAdmin = async () => {
const { provider } = strapi.plugins.upload;
const isPrivate = await provider.isPrivate();
// We only need to sign the file urls if the provider is private
if (!isPrivate) {
return;
}
strapi.container
.get('services')
.extend('plugin::content-manager.entity-manager', (entityManager) => {
/**
* Map entity manager responses to sign private media URLs
* @param {Object} entity
* @param {string} uid
* @returns
*/
const mapEntity = async (entity, uid) => {
const mappedEntity = await entityManager.mapEntity(entity, uid);
return signEntityMedia(mappedEntity, uid);
};
return { ...entityManager, mapEntity };
});
};
module.exports = {
addSignedFileUrlsToAdmin,
signEntityMedia,
};

View File

@ -0,0 +1,7 @@
'use strict';
const { addSignedFileUrlsToAdmin } = require('./entity-manager');
module.exports = {
entityManager: { addSignedFileUrlsToAdmin },
};

View File

@ -0,0 +1,7 @@
'use strict';
const contentManagerExtensions = require('./content-manager');
module.exports = {
contentManager: contentManagerExtensions,
};

View File

@ -1,7 +1,8 @@
'use strict';
const { cloneDeep } = require('lodash/fp');
const { mapAsync } = require('@strapi/utils');
const { FOLDER_MODEL_UID, FILE_MODEL_UID } = require('../constants');
const { getService } = require('../utils');
const getFolderPath = async (folderId) => {
@ -22,7 +23,34 @@ const deleteByIds = async (ids = []) => {
return filesToDelete;
};
const signFileUrls = async (file) => {
const { provider } = strapi.plugins.upload;
const { provider: providerConfig } = strapi.config.get('plugin.upload');
const isPrivate = await provider.isPrivate();
// Check file provider and if provider is private
if (file.provider !== providerConfig || !isPrivate) {
return file;
}
const signUrl = async (file) => {
const signedUrl = await provider.getSignedUrl(file);
file.url = signedUrl.url;
};
const signedFile = cloneDeep(file);
// Sign each file format
await signUrl(signedFile);
if (file.formats) {
await mapAsync(Object.values(signedFile.formats), signUrl);
}
return signedFile;
};
module.exports = {
getFolderPath,
deleteByIds,
signFileUrls,
};

View File

@ -5,15 +5,19 @@ const upload = require('./upload');
const imageManipulation = require('./image-manipulation');
const folder = require('./folder');
const file = require('./file');
const weeklyMetrics = require('./metrics/weekly-metrics');
const metrics = require('./metrics');
const apiUploadFolder = require('./api-upload-folder');
const extensions = require('./extensions');
module.exports = {
provider,
upload,
folder,
file,
weeklyMetrics,
metrics,
'image-manipulation': imageManipulation,
'api-upload-folder': apiUploadFolder,
extensions,
};

View File

@ -0,0 +1,18 @@
'use strict';
const getProviderName = () => strapi.config.get('plugin.upload.provider', 'local');
const isProviderPrivate = async () => strapi.plugin('upload').provider.isPrivate();
module.exports = ({ strapi }) => ({
async sendUploadPluginMetrics() {
const uploadProvider = getProviderName();
const privateProvider = await isProviderPrivate();
strapi.telemetry.send('didInitializePluginUpload', {
groupProperties: {
uploadProvider,
privateProvider,
},
});
},
});

View File

@ -2,8 +2,8 @@
const { defaultTo } = require('lodash/fp');
const { add } = require('date-fns');
const { FOLDER_MODEL_UID, FILE_MODEL_UID } = require('../constants');
const { getWeeklyCronScheduleAt } = require('../utils/cron');
const { FOLDER_MODEL_UID, FILE_MODEL_UID } = require('../../constants');
const { getWeeklyCronScheduleAt } = require('../../utils/cron');
const ONE_WEEK = 7 * 24 * 60 * 60 * 1000;

View File

@ -0,0 +1,187 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { createTestBuilder } = require('../../../../../test/helpers/builder');
const { createStrapiInstance } = require('../../../../../test/helpers/strapi');
const { createAuthRequest } = require('../../../../../test/helpers/request');
const builder = createTestBuilder();
let strapi;
let baseRequest;
let rq;
const modelUID = 'api::model.model';
const componentUID = 'default.component';
const models = {
[modelUID]: {
displayName: 'Model',
singularName: 'model',
pluralName: 'models',
kind: 'collectionType',
attributes: {
name: {
type: 'text',
},
media: {
type: 'media',
},
media_repeatable: {
type: 'media',
multiple: true,
},
compo_media: {
type: 'component',
component: componentUID,
},
compo_media_repeatable: {
type: 'component',
repeatable: true,
component: componentUID,
},
dynamicZone: {
type: 'dynamiczone',
components: [componentUID],
},
},
},
[componentUID]: {
displayName: 'component',
attributes: {
media_repeatable: {
type: 'media',
multiple: true,
},
media: {
type: 'media',
multiple: false,
},
},
},
};
const mockProvider = (signUrl = true) => ({
init() {
return {
isPrivate() {
return signUrl;
},
getSignedUrl() {
return { url: 'signedUrl' };
},
uploadStream() {},
upload() {},
delete() {},
checkFileSize() {},
};
},
});
const uploadImg = (fileName) => {
return baseRequest({
method: 'POST',
url: '/upload',
formData: {
files: fs.createReadStream(path.join(__dirname, `../utils/${fileName}`)),
},
});
};
let repeatable;
let singleMedia;
let mediaEntry = {};
describe('Upload Plugin url signing', () => {
const responseExpectations = (result) => {
expect(result.statusCode).toBe(200);
expect(result.body.media.url).toEqual('signedUrl');
for (const media of result.body.media_repeatable) {
expect(media.url).toEqual('signedUrl');
}
expect(result.body.compo_media.media.url).toEqual('signedUrl');
for (const media of result.body.compo_media.media_repeatable) {
expect(media.url).toEqual('signedUrl');
}
for (const component of result.body.compo_media_repeatable) {
expect(component.media.url).toEqual('signedUrl');
for (const media of component.media_repeatable) {
expect(media.url).toEqual('signedUrl');
}
}
for (const component of result.body.dynamicZone) {
expect(component.media.url).toEqual('signedUrl');
for (const media of component.media_repeatable) {
expect(media.url).toEqual('signedUrl');
}
}
};
beforeAll(async () => {
const localProviderPath = require.resolve('@strapi/provider-upload-local');
jest.mock(localProviderPath, () => mockProvider(true));
// Create builder
await builder.addComponent(models[componentUID]).addContentType(models[modelUID]).build();
// Create api instance
strapi = await createStrapiInstance();
baseRequest = await createAuthRequest({ strapi });
rq = await createAuthRequest({ strapi });
rq.setURLPrefix(`/content-manager/collection-types/${modelUID}`);
const imgRes = [await uploadImg('rec.jpg'), await uploadImg('strapi.jpg')];
repeatable = imgRes.map((img) => img.body[0].id);
singleMedia = imgRes[0].body[0].id;
mediaEntry = {
media: singleMedia,
media_repeatable: repeatable,
};
});
afterAll(async () => {
await strapi.destroy();
await builder.cleanup();
});
test('returns signed media URLs on content creation', async () => {
const creationResult = await rq.post('/', {
body: {
name: 'name',
media: singleMedia,
media_repeatable: repeatable,
compo_media: mediaEntry,
compo_media_repeatable: [mediaEntry, mediaEntry],
dynamicZone: [
{
__component: componentUID,
...mediaEntry,
},
],
},
qs: {
populate: ['name'],
},
});
responseExpectations(creationResult);
});
test('returns signed media URLs when we GET content', async () => {
const result = await baseRequest({
method: 'GET',
url: `/content-manager/collection-types/${modelUID}/1`,
});
responseExpectations(result);
});
});

View File

@ -25,6 +25,10 @@ npm install @strapi/provider-upload-aws-s3 --save
- `provider` defines the name of the provider
- `providerOptions` is passed down during the construction of the provider. (ex: `new AWS.S3(config)`). [Complete list of options](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#constructor-property)
- `providerOptions.params` is passed directly to the parameters to each method respectively.
- `ACL` is the access control list for the object. Defaults to `public-read`.
- `signedUrlExpires` is the number of seconds before a signed URL expires. (See [how signed URLs work](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-signed-urls.html)). Defaults to 15 minutes and URLs are only signed when ACL is set to `private`.
- `Bucket` is the name of the bucket to upload to.
- `actionOptions` is passed directly to the parameters to each method respectively. You can find the complete list of [upload/ uploadStream options](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#upload-property) and [delete options](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#deleteObject-property)
See the [documentation about using a provider](https://docs.strapi.io/developer-docs/latest/plugins/upload.html#using-a-provider) for information on installing and using a provider. To understand how environment variables are used in Strapi, please refer to the [documentation about environment variables](https://docs.strapi.io/developer-docs/latest/setup-deployment-guides/configurations/optional/environment.html#environment-variables).
@ -49,9 +53,46 @@ module.exports = ({ env }) => ({
secretAccessKey: env('AWS_ACCESS_SECRET'),
region: env('AWS_REGION'),
params: {
ACL: env('AWS_ACL', 'public-read'),
signedUrlExpires: env('AWS_SIGNED_URL_EXPIRES', 15 * 60),
Bucket: env('AWS_BUCKET'),
},
}
},
},
actionOptions: {
upload: {},
uploadStream: {},
delete: {},
},
},
},
// ...
});
```
### Configuration for a private S3 bucket
If your bucket is configured to be private, you will need to set the `ACL` option to `private` in the `params` object. This will ensure that the signed URL is generated with the correct permissions.
You can also define the expiration time of the signed URL by setting the `signedUrlExpires` option in the `params` object. The default value is 7 days.
`./config/plugins.js`
```js
module.exports = ({ env }) => ({
// ...
upload: {
config: {
provider: 'aws-s3',
providerOptions: {
accessKeyId: env('AWS_ACCESS_KEY_ID'),
secretAccessKey: env('AWS_ACCESS_SECRET'),
region: env('AWS_REGION'),
params: {
ACL: 'private', // <== set ACL to private
signedUrlExpires: env('AWS_SIGNED_URL_EXPIRES', 60 * 60 * 24 * 7),
Bucket: env('AWS_BUCKET'),
},
},
actionOptions: {
upload: {},

View File

@ -0,0 +1,43 @@
'use strict';
const { getBucketFromUrl } = require('../utils');
describe('Test for URLs', () => {
test('Virtual hosted style', async () => {
const url = 'https://bucket.s3.us-east-1.amazonaws.com/img.png';
const { bucket } = getBucketFromUrl(url);
expect(bucket).toEqual('bucket');
});
describe('Path style', () => {
test('No key', async () => {
const url = 'https://s3.us-east-1.amazonaws.com/bucket';
const { bucket } = getBucketFromUrl(url);
expect(bucket).toEqual('bucket');
});
test('With trailing slash', async () => {
const url = 'https://s3.us-east-1.amazonaws.com/bucket/';
const { bucket } = getBucketFromUrl(url);
expect(bucket).toEqual('bucket');
});
test('With key', async () => {
const url = 'https://s3.us-east-1.amazonaws.com/bucket/img.png';
const { bucket } = getBucketFromUrl(url);
expect(bucket).toEqual('bucket');
});
});
test('S3 access point', async () => {
const url = 'https://bucket.s3-accesspoint.us-east-1.amazonaws.com';
const { bucket } = getBucketFromUrl(url);
expect(bucket).toEqual('bucket');
});
test('S3://', async () => {
const url = 'S3://bucket/img.png';
const { bucket } = getBucketFromUrl(url);
expect(bucket).toEqual('bucket');
});
});

View File

@ -6,8 +6,9 @@
/* eslint-disable no-unused-vars */
// Public node modules.
const _ = require('lodash');
const { getOr } = require('lodash/fp');
const AWS = require('aws-sdk');
const { getBucketFromUrl } = require('./utils');
function assertUrlProtocol(url) {
// Regex to test protocol like "http://", "https://"
@ -22,10 +23,11 @@ module.exports = {
);
}
const config = { ...s3Options, ...legacyS3Options };
const S3 = new AWS.S3({
apiVersion: '2006-03-01',
...s3Options,
...legacyS3Options,
...config,
});
const filePrefix = rootPath ? `${rootPath.replace(/\/+$/, '')}/` : '';
@ -36,6 +38,8 @@ module.exports = {
return `${filePrefix}${path}${file.hash}${file.ext}`;
};
const ACL = getOr('public-read', ['params', 'ACL'], config);
const upload = (file, customParams = {}) =>
new Promise((resolve, reject) => {
// upload file on S3 bucket
@ -44,7 +48,7 @@ module.exports = {
{
Key: fileKey,
Body: file.stream || Buffer.from(file.buffer, 'binary'),
ACL: 'public-read',
ACL,
ContentType: file.mime,
...customParams,
},
@ -60,13 +64,49 @@ module.exports = {
// Default protocol to https protocol
file.url = `https://${data.Location}`;
}
resolve();
}
);
});
return {
isPrivate() {
return ACL === 'private';
},
/**
* @param {Object} file
* @param {string} file.path
* @param {string} file.hash
* @param {string} file.ext
* @param {Object} customParams
* @returns {Promise<{url: string}>}
*/
getSignedUrl(file, customParams = {}) {
// Do not sign the url if it does not come from the same bucket.
const { bucket } = getBucketFromUrl(file.url);
if (bucket !== config.params.Bucket) {
return { url: file.url };
}
return new Promise((resolve, reject) => {
const fileKey = getFileKey(file);
S3.getSignedUrl(
'getObject',
{
Bucket: config.params.Bucket,
Key: fileKey,
Expires: getOr(15 * 60, ['params', 'signedUrlExpires'], config), // 15 minutes
},
(err, url) => {
if (err) {
return reject(err);
}
resolve({ url });
}
);
});
},
uploadStream(file, customParams = {}) {
return upload(file, customParams);
},

View File

@ -0,0 +1,63 @@
'use strict';
const ENDPOINT_PATTERN = /^(.+\.)?s3[.-]([a-z0-9-]+)\./;
/**
* Parse the bucket name from a URL.
* See all URL formats in https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-bucket-intro.html
*
* @param {string} fileUrl - the URL to parse
* @returns {object} result
* @returns {string} result.bucket - the bucket name
* @returns {string} result.error - if any
*/
function getBucketFromUrl(fileUrl) {
const uri = new URL(fileUrl);
// S3://<bucket-name>/<key>
if (uri.protocol === 's3:') {
const bucket = uri.host;
if (!bucket) {
return { err: `Invalid S3 URI: no bucket: ${uri}` };
}
return { bucket };
}
if (!uri.host) {
return { err: `Invalid S3 URI: no hostname: ${uri}` };
}
const matches = uri.host.match(ENDPOINT_PATTERN);
if (!matches) {
return { err: `Invalid S3 URI: hostname does not appear to be a valid S3 endpoint: ${uri}` };
}
const prefix = matches[1];
// https://s3.amazonaws.com/<bucket-name>
if (!prefix) {
if (uri.pathname === '/') {
return { bucket: null };
}
const index = uri.pathname.indexOf('/', 1);
// https://s3.amazonaws.com/<bucket-name>
if (index === -1) {
return { bucket: uri.pathname.substring(1) };
}
// https://s3.amazonaws.com/<bucket-name>/
if (index === uri.pathname.length - 1) {
return { bucket: uri.pathname.substring(1, index) };
}
// https://s3.amazonaws.com/<bucket-name>/key
return { bucket: uri.pathname.substring(1, index) };
}
// https://<bucket-name>.s3.amazonaws.com/
return { bucket: prefix.substring(0, prefix.length - 1) };
}
module.exports = { getBucketFromUrl };

View File

@ -1,7 +1,6 @@
{
"name": "@strapi/babel-plugin-switch-ee-ce",
"version": "4.8.2",
"private": false,
"description": "Babel plugin to switch from CE to EE at runtime",
"repository": "git://github.com/strapi/strapi.git",
"license": "SEE LICENSE IN LICENSE",

View File

@ -6,8 +6,19 @@
"dependencies": {
"@babel/eslint-parser": "^7.19.1",
"@strapi/eslint-config": "0.1.2",
"@typescript-eslint/eslint-plugin": "^5.55.0",
"@typescript-eslint/parser": "5.43.0",
"babel-eslint": "10.1.0",
"eslint": "8.27.0",
"babel-eslint": "10.1.0"
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-airbnb-typescript": "^17.0.0",
"eslint-config-prettier": "^8.7.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0"
}
}

View File

@ -24,7 +24,7 @@
}
],
"main": "./dist/index.js",
"types": "./dist/index.ts",
"types": "./dist/index.d.ts",
"files": [
"./dist"
],

View File

@ -2,4 +2,4 @@
"name": "tsconfig",
"version": "0.0.0",
"private": true
}
}

View File

@ -4,7 +4,7 @@
"silent": true,
"autoJobCancelation": true
},
"installCommand": "yarn setup",
"installCommand": "yarn install --immutable",
"ignoreCommand": "git diff HEAD^ HEAD --quiet './packages/core/helper-plugin'",
"outputDirectory": "packages/core/helper-plugin/storybook-static"
}

55027
yarn.lock

File diff suppressed because it is too large Load Diff