diff --git a/README.md b/README.md index 8595ba7..faade58 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ This plugin supports the following adapters: - [Azure Blob Storage](#azure-blob-storage-adapter) - [AWS S3-style Storage](#s3-adapter) - [Google Cloud Storage](#gcs-adapter) +- [Supabase Storage Adapter](#supabase-adapter) However, you can create your own adapter for any third-party service you would like to use. @@ -175,6 +176,26 @@ const adapter = gcsAdapter({ // Now you can pass this adapter to the plugin ``` +### Supabase Adapter + +To use the Supabase adapter, some peer dependencies need to be installed: + +`yarn add @supabase/storage-js fast-blob-stream`. + +From there, create the adapter, passing in all of its required properties: + +```js +import { supabaseAdapter } from '@payloadcms/plugin-cloud-storage/supabase'; + +const adapter = supabaseAdapter({ + apiKey: process.env.SUPABASE_SECRET_KEY,// this env variable will have the service_role key of your supabase project + bucket: process.env.SUPABASE_BUCKET_NAME,// this env variable will have the bucket name + url: process.env.SUPABASE_ENDPOINT,// this env variable will have the endpoint of your supabase project +}) + +// Now you can pass this adapter to the plugin +``` + ### Payload Access Control Payload ships with access control that runs *even on statically served files*. The same `read` access control property on your `upload`-enabled collections is used, and it allows you to restrict who can request your uploaded files. diff --git a/dev/.env.example b/dev/.env.example index 7b69673..f6843b9 100644 --- a/dev/.env.example +++ b/dev/.env.example @@ -18,4 +18,8 @@ GCS_ENDPOINT=http://localhost:4443 GCS_PROJECT_ID=test GCS_BUCKET=payload-bucket +SUPABASE_SECRET_KEY=supabase-secret-key +SUPABASE_BUCKET_NAME=payload-bucket +SUPABASE_ENDPOINT=https://localhost:10000/your-endpoint + PAYLOAD_DROP_DATABASE=true diff --git a/dev/package.json b/dev/package.json index a9f5cdf..16b0535 100644 --- a/dev/package.json +++ b/dev/package.json @@ -8,6 +8,7 @@ "dev:azure": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts PAYLOAD_PUBLIC_CLOUD_STORAGE_ADAPTER=azure nodemon", "dev:s3": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts PAYLOAD_PUBLIC_CLOUD_STORAGE_ADAPTER=s3 nodemon", "dev:gcs": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts PAYLOAD_PUBLIC_CLOUD_STORAGE_ADAPTER=gcs nodemon", + "dev:supabase": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts PAYLOAD_PUBLIC_CLOUD_STORAGE_ADAPTER=supabase nodemon", "build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build", "build:server": "tsc", "build": "yarn build:payload && yarn build:server", @@ -18,8 +19,10 @@ "@aws-sdk/client-s3": "^3.142.0", "@azure/storage-blob": "^12.11.0", "@google-cloud/storage": "^6.4.2", + "@supabase/storage-js": "^2.3.0", "dotenv": "^8.2.0", "express": "^4.17.1", + "fast-blob-stream": "^1.1.1", "image-size": "^1.0.2", "payload": "^1.7.2", "probe-image-size": "^7.2.3" diff --git a/dev/src/payload.config.ts b/dev/src/payload.config.ts index 3381130..db907dd 100644 --- a/dev/src/payload.config.ts +++ b/dev/src/payload.config.ts @@ -1,16 +1,25 @@ -import { buildConfig } from 'payload/config' import path from 'path' -import Users from './collections/Users' +import { buildConfig } from 'payload/config' import { cloudStorage } from '../../src' -import { s3Adapter } from '../../src/adapters/s3' -import { gcsAdapter } from '../../src/adapters/gcs' import { azureBlobStorageAdapter } from '../../src/adapters/azure' +import { gcsAdapter } from '../../src/adapters/gcs' +import { s3Adapter } from '../../src/adapters/s3' +import { supabaseAdapter } from '../../src/adapters/supabase' import type { Adapter } from '../../src/types' import { Media } from './collections/Media' +import Users from './collections/Users' let adapter: Adapter let uploadOptions +if (process.env.PAYLOAD_PUBLIC_CLOUD_STORAGE_ADAPTER === 'supabase') { + adapter = supabaseAdapter({ + apiKey: process.env.SUPABASE_SECRET_KEY, + bucket: process.env.SUPABASE_BUCKET_NAME, + url: process.env.SUPABASE_ENDPOINT, + }) +} + if (process.env.PAYLOAD_PUBLIC_CLOUD_STORAGE_ADAPTER === 'azure') { adapter = azureBlobStorageAdapter({ connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING, @@ -85,6 +94,10 @@ export default buildConfig({ __dirname, '../../src/adapters/azure/mock.js', ), + [path.resolve(__dirname, '../../src/adapters/supabase/index')]: path.resolve( + __dirname, + '../../src/adapters/supabase/mock.js', + ) }, }, } diff --git a/docs/local-dev.md b/docs/local-dev.md index ca2de04..9e29403 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -45,3 +45,13 @@ The default `./dev/.env.example` file comes pre-loaded with correct `env` variab Otherwise, if you are not using the emulator, make sure your environment variables within `./dev/.env` are configured for your Google connection. Finally, to start the Payload dev server with the GCS adapter, run `yarn dev:gcs` and then open `http://localhost:3000/admin` in your browser. + +### Supabase Adapter Development + +By now, this repository does not come with a Docker emulator for Supabase Storage. + +If you would like to test locally this plugin, use the following steps: + +1. Sign up for a free plan in Supabase platform. +1. Make sure your environment variables within `./dev/.env` are configured for your Supabase connection. +1. Finally, run `yarn dev:supabase` and then open `http://localhost:3000/admin` in your browser. diff --git a/package.json b/package.json index 27bfeb4..cd36efc 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "@aws-sdk/lib-storage": "^3.267.0", "@azure/storage-blob": "^12.11.0", "@google-cloud/storage": "^6.4.1", + "@supabase/storage-js": "^2.3.0", + "fast-blob-stream": "^1.1.1", "payload": "^1.7.2" }, "peerDependenciesMeta": { @@ -31,6 +33,12 @@ }, "@google-cloud/storage": { "optional": true + }, + "@supabase/storage-js": { + "optional": true + }, + "fast-blob-stream": { + "optional": true } }, "files": [ @@ -44,6 +52,7 @@ "@aws-sdk/lib-storage": "^3.267.0", "@azure/storage-blob": "^12.11.0", "@google-cloud/storage": "^6.4.1", + "@supabase/storage-js": "^2.3.0", "@types/express": "^4.17.9", "@typescript-eslint/eslint-plugin": "5.12.1", "@typescript-eslint/parser": "5.12.1", @@ -54,6 +63,7 @@ "eslint-config-prettier": "^8.5.0", "eslint-plugin-import": "2.25.4", "eslint-plugin-prettier": "^4.0.0", + "fast-blob-stream": "^1.1.1", "nodemon": "^2.0.6", "payload": "^1.7.2", "prettier": "^2.7.1", @@ -61,7 +71,11 @@ "ts-node": "^9.1.1", "typescript": "^4.1.3" }, +<<<<<<< HEAD + "dependencies": {} +======= "dependencies": { "range-parser": "^1.2.1" } +>>>>>>> master } diff --git a/src/adapters/supabase/fileStub.js b/src/adapters/supabase/fileStub.js new file mode 100644 index 0000000..e25c9a3 --- /dev/null +++ b/src/adapters/supabase/fileStub.js @@ -0,0 +1 @@ +export default 'file-stub' diff --git a/src/adapters/supabase/generateURL.ts b/src/adapters/supabase/generateURL.ts new file mode 100644 index 0000000..8225d0d --- /dev/null +++ b/src/adapters/supabase/generateURL.ts @@ -0,0 +1,13 @@ +import path from 'path' +import type { GenerateURL } from '../../types' + +interface Args { + endpoint: string + bucket: string +} + +export const getGenerateURL = + ({ endpoint, bucket }: Args): GenerateURL => + ({ filename, prefix = '' }) => { + return `${endpoint}/object/public/${bucket}/${path.posix.join(prefix, filename)}` + } diff --git a/src/adapters/supabase/handleDelete.ts b/src/adapters/supabase/handleDelete.ts new file mode 100644 index 0000000..d93c76c --- /dev/null +++ b/src/adapters/supabase/handleDelete.ts @@ -0,0 +1,15 @@ +import type { StorageClient } from '@supabase/storage-js' +import path from 'path' +import type { HandleDelete } from '../../types' + +interface Args { + getStorageClient: () => StorageClient + bucket: string +} + +export const getHandleDelete = ({ getStorageClient, bucket }: Args): HandleDelete => { + return async ({ filename, doc: { prefix = '' } }) => { + const fileKey: string = path.posix.join(prefix, filename) + await getStorageClient().from(bucket).remove([fileKey]) + } +} diff --git a/src/adapters/supabase/handleUpload.ts b/src/adapters/supabase/handleUpload.ts new file mode 100644 index 0000000..675d814 --- /dev/null +++ b/src/adapters/supabase/handleUpload.ts @@ -0,0 +1,27 @@ +import type { StorageClient } from '@supabase/storage-js' +import fs from 'fs' +import path from 'path' +import type stream from 'stream' +import type { HandleUpload } from '../../types' + +interface Args { + getStorageClient: () => StorageClient + bucket: string + prefix?: string +} + +export const getHandleUpload = ({ getStorageClient, bucket, prefix = '' }: Args): HandleUpload => { + return async ({ data, file }) => { + const fileKey = path.posix.join(prefix, file.filename) + + const fileBufferOrStream: Buffer | stream.Readable = file.tempFilePath + ? fs.createReadStream(file.tempFilePath) + : file.buffer + + await getStorageClient().from(bucket).upload(fileKey, fileBufferOrStream, { + contentType: file.mimeType, + }) + + return data + } +} diff --git a/src/adapters/supabase/index.ts b/src/adapters/supabase/index.ts new file mode 100644 index 0000000..ea50ab6 --- /dev/null +++ b/src/adapters/supabase/index.ts @@ -0,0 +1,35 @@ +import { StorageClient } from '@supabase/storage-js' +import type { Adapter, GeneratedAdapter } from '../../types' +import { getGenerateURL } from './generateURL' +import { getHandleDelete } from './handleDelete' +import { getHandleUpload } from './handleUpload' +import { getHandler } from './staticHandler' +import { extendWebpackConfig } from './webpack' + +export interface Args { + url: string + apiKey: string + bucket: string +} + +export const supabaseAdapter = + ({ url, apiKey, bucket }: Args): Adapter => + ({ collection, prefix }): GeneratedAdapter => { + let storageClient: StorageClient | null = null + const getStorageClient: () => StorageClient = () => { + if (storageClient) return storageClient + storageClient = new StorageClient(url, { + apikey: apiKey, + Authorization: `Bearer ${apiKey}`, + }) + return storageClient + } + + return { + handleUpload: getHandleUpload({ getStorageClient, bucket, prefix }), + handleDelete: getHandleDelete({ getStorageClient, bucket }), + generateURL: getGenerateURL({ bucket, endpoint: url }), + staticHandler: getHandler({ getStorageClient, bucket, collection }), + webpack: extendWebpackConfig, + } + } diff --git a/src/adapters/supabase/mock.js b/src/adapters/supabase/mock.js new file mode 100644 index 0000000..2cdfe4d --- /dev/null +++ b/src/adapters/supabase/mock.js @@ -0,0 +1,4 @@ +exports.StorageClient = () => null +exports.Upload = () => null +exports.getBucket = () => null +exports.BlobReadStream = () => null diff --git a/src/adapters/supabase/staticHandler.ts b/src/adapters/supabase/staticHandler.ts new file mode 100644 index 0000000..018a77a --- /dev/null +++ b/src/adapters/supabase/staticHandler.ts @@ -0,0 +1,46 @@ +import type { StorageClient } from '@supabase/storage-js' +import { BlobReadStream } from 'fast-blob-stream' +import path from 'path' +import type { CollectionConfig } from 'payload/types' +import type { StaticHandler } from '../../types' +import { getFilePrefix } from '../../utilities/getFilePrefix' + +interface Args { + getStorageClient: () => StorageClient + bucket: string + collection: CollectionConfig +} + +export const getHandler = ({ getStorageClient, bucket, collection }: Args): StaticHandler => { + return async (req, res, next) => { + try { + const prefix = await getFilePrefix({ req, collection }) + const key: string = path.posix.join(prefix, req.params.filename) + + const { data } = await getStorageClient().from(bucket).list('', { + limit: 1, + offset: 0, + search: key, + }) + const file = data![0] + const fileDownloaded = await getStorageClient().from(bucket).download(key) + const blobFile = fileDownloaded.data + + res.set({ + 'Content-Length': file.metadata.contentLength, + 'Content-Type': file.metadata.mimetype, + ETag: file.metadata.eTag, + }) + + if (blobFile) { + const readStream = new BlobReadStream(blobFile) + return readStream.pipe(res) + } + + return next() + } catch (err: unknown) { + req.payload.logger.error(err) + return next() + } + } +} diff --git a/src/adapters/supabase/webpack.ts b/src/adapters/supabase/webpack.ts new file mode 100644 index 0000000..85e2a06 --- /dev/null +++ b/src/adapters/supabase/webpack.ts @@ -0,0 +1,19 @@ +import path from 'path' +import type { Configuration as WebpackConfig } from 'webpack' + +export const extendWebpackConfig = (existingWebpackConfig: WebpackConfig): WebpackConfig => { + const newConfig: WebpackConfig = { + ...existingWebpackConfig, + resolve: { + ...(existingWebpackConfig.resolve || {}), + alias: { + ...(existingWebpackConfig.resolve?.alias ? existingWebpackConfig.resolve.alias : {}), + '@supabase/storage-js': path.resolve(__dirname, './mock.js'), + 'fast-blob-stream': path.resolve(__dirname, './mock.js'), + fs: path.resolve(__dirname, './fileStub.js'), + }, + }, + } + + return newConfig +} diff --git a/yarn.lock b/yarn.lock index af29254..b4e169f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1583,6 +1583,13 @@ resolved "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== +"@supabase/storage-js@^2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@supabase/storage-js/-/storage-js-2.3.0.tgz#296b36eb0bde378763117217b2f99303f1d4ec71" + integrity sha512-YGWVCEYYYF3+UiyL8O4xC78N9n9paLbT0hHl8dmYAtd3DqyWtu5Eph9JTu0PWm+/29Zhns5TbhUZW4xpWjJfPQ== + dependencies: + cross-fetch "^3.1.5" + "@swc/core-darwin-arm64@1.3.34": version "1.3.34" resolved "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.34.tgz#1885fec4bd734c840897a68937a52ecab06cffbb" @@ -2801,6 +2808,13 @@ cross-env@^7.0.3: dependencies: cross-spawn "^7.0.1" +cross-fetch@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" + integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== + dependencies: + node-fetch "2.6.7" + cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -3697,6 +3711,14 @@ extend@^3.0.2: resolved "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== +fast-blob-stream@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/fast-blob-stream/-/fast-blob-stream-1.1.1.tgz#3ed549efb6cf302a20a409e32fda41f2ba6e4871" + integrity sha512-wdRazMMeM2pl8hq1lFG8fzix8p1VLAJunTTE2RADiFBwbUfZwybUm6IwPrmMS7qTthiayr166NoXeqWe3hfR5w== + dependencies: + fast-readable-async-iterator "^1.1.1" + streamx "^2.12.4" + fast-copy@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.0.tgz#875ebf33b13948ae012b6e51d33da5e6e7571ab8" @@ -3712,6 +3734,11 @@ fast-diff@^1.1.2: resolved "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03" integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w== +fast-fifo@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.1.0.tgz#17d1a3646880b9891dfa0c54e69c5fef33cad779" + integrity sha512-Kl29QoNbNvn4nhDsLYjyIAaIqaJB6rBx5p3sL9VjaefJ+eMFBWVZiaoguaoZfzEKr5RhAti0UgM8703akGPJ6g== + fast-glob@^3.2.9: version "3.2.12" resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80" @@ -3733,6 +3760,11 @@ fast-levenshtein@^2.0.6: resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-readable-async-iterator@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/fast-readable-async-iterator/-/fast-readable-async-iterator-1.1.1.tgz#77dfbb5262b278bb123c4d8d3219b1bb881b857c" + integrity sha512-xEHkLUEmStETI+15zhglJLO9TjXxNkkp2ldEfYVZdcqxFhM172EfGl1irI6mVlTxXspYKH1/kjevnt/XSsPeFA== + fast-redact@^3.0.0: version "3.1.2" resolved "https://registry.npmjs.org/fast-redact/-/fast-redact-3.1.2.tgz#d58e69e9084ce9fa4c1a6fa98a3e1ecf5d7839aa" @@ -5318,6 +5350,13 @@ node-addon-api@^5.0.0: resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz#49da1ca055e109a23d537e9de43c09cca21eb762" integrity sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA== +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" + node-fetch@^2.6.1, node-fetch@^2.6.7: version "2.6.9" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz#7c7f744b5cc6eb5fd404e0c7a9fec630a55657e6" @@ -6497,6 +6536,11 @@ queue-microtask@^1.2.2: resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +queue-tick@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/queue-tick/-/queue-tick-1.0.1.tgz#f6f07ac82c1fd60f82e098b417a80e52f1f4c142" + integrity sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag== + quick-format-unescaped@^4.0.3: version "4.0.4" resolved "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz#93ef6dd8d3453cbc7970dd614fad4c5954d6b5a7" @@ -7270,6 +7314,14 @@ streamsearch@^1.1.0: resolved "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== +streamx@^2.12.4: + version "2.13.2" + resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.13.2.tgz#9de43569a1cd54980d128673b3c1429b79afff1c" + integrity sha512-+TWqixPhGDXEG9L/XczSbhfkmwAtGs3BJX5QNU6cvno+pOLKeszByWcnaTu6dg8efsTYqR8ZZuXWHhZfgrxMvA== + dependencies: + fast-fifo "^1.1.0" + queue-tick "^1.0.1" + string.prototype.trimend@^1.0.6: version "1.0.6" resolved "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz#c4a27fa026d979d79c04f17397f250a462944533"