diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b21808..2cdc898 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: - main - next - rc + - next-rc-1 workflow_dispatch: jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 875729e..caa1133 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,7 @@ on: - main - next - rc + - next-rc-1 workflow_dispatch: jobs: diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index aa9a209..ea54bf1 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -17,7 +17,8 @@ services: ports: - '3000:3000' depends_on: - - db + storage: + condition: service_healthy restart: always environment: PGRST_DB_URI: postgres://postgres:postgres@db:5432/postgres @@ -25,12 +26,13 @@ services: PGRST_DB_ANON_ROLE: postgres PGRST_JWT_SECRET: super-secret-jwt-token-with-at-least-32-characters-long storage: - image: supabase/storage-api:v0.20.2 + build: + context: ./storage ports: - '5000:5000' depends_on: - - db - - rest + db: + condition: service_healthy restart: always environment: ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYxMzUzMTk4NSwiZXhwIjoxOTI5MTA3OTg1fQ.ReNhHIoXIOa-8tL1DO3e26mJmOTnYuvdgobwIYGzrLQ @@ -47,6 +49,12 @@ services: FILE_SIZE_LIMIT: 52428800 STORAGE_BACKEND: file FILE_STORAGE_BACKEND_PATH: /tmp/storage + ENABLE_IMAGE_TRANSFORMATION: "true" + IMGPROXY_URL: http://imgproxy:8080 + volumes: + - assets-volume:/tmp/storage + healthcheck: + test: ['CMD-SHELL', 'curl -f -LI http://localhost:5000/status'] db: build: context: ./postgres @@ -61,3 +69,20 @@ services: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_PORT: 5432 + healthcheck: + test: [ "CMD-SHELL", "pg_isready" ] + interval: 10s + timeout: 5s + retries: 5 + + imgproxy: + image: darthsim/imgproxy + ports: + - 50020:8080 + volumes: + - assets-volume:/tmp/storage + environment: + - IMGPROXY_LOCAL_FILESYSTEM_ROOT=/ + - IMGPROXY_USE_ETAG=true +volumes: + assets-volume: \ No newline at end of file diff --git a/infra/postgres/dummy-data.sql b/infra/postgres/dummy-data.sql index 0dabe37..8aef852 100644 --- a/infra/postgres/dummy-data.sql +++ b/infra/postgres/dummy-data.sql @@ -51,4 +51,6 @@ CREATE POLICY authenticated_folder ON storage.objects for all USING (bucket_id=' -- allow CRUD access to a folder in bucket2 to its owners CREATE POLICY crud_owner_only ON storage.objects for all USING (bucket_id='bucket2' and (storage.foldername(name))[1] = 'only_owner' and owner = auth.uid()); -- allow CRUD access to bucket4 -CREATE POLICY open_all_update ON storage.objects for all WITH CHECK (bucket_id='bucket4'); \ No newline at end of file +CREATE POLICY open_all_update ON storage.objects for all WITH CHECK (bucket_id='bucket4'); + +CREATE POLICY crud_my_bucket ON storage.objects for all USING (bucket_id='my-private-bucket' and auth.uid()::text = '317eadce-631a-4429-a0bb-f19a7a517b4a'); \ No newline at end of file diff --git a/infra/storage/Dockerfile b/infra/storage/Dockerfile new file mode 100644 index 0000000..31d6653 --- /dev/null +++ b/infra/storage/Dockerfile @@ -0,0 +1,3 @@ +FROM supabase/storage-api:v0.25.1 + +RUN apk add curl --no-cache \ No newline at end of file diff --git a/src/lib/types.ts b/src/lib/types.ts index a533cf3..2a66dd8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -72,3 +72,21 @@ export interface FetchParameters { export interface Metadata { name: string } + +export interface TransformOptions { + /** + * The width of the image in pixels. + */ + width?: number + /** + * The height of the image in pixels. + */ + height?: number + /** + * The resize mode can be cover, contain or fill. Defaults to cover. + * Cover resizes the image to maintain it's aspect ratio while filling the entire width and height. + * Contain resizes the image to maintain it's aspect ratio while fitting the entire image within the width and height. + * Fill resizes the image to fill the entire width and height. If the object's aspect ratio does not match the width and height, the image will be stretched to fit. + */ + resize?: 'cover' | 'contain' | 'fill' +} diff --git a/src/packages/StorageFileApi.ts b/src/packages/StorageFileApi.ts index 4b0277a..daf3911 100644 --- a/src/packages/StorageFileApi.ts +++ b/src/packages/StorageFileApi.ts @@ -1,7 +1,13 @@ import { isStorageError, StorageError } from '../lib/errors' import { Fetch, get, post, remove } from '../lib/fetch' import { resolveFetch } from '../lib/helpers' -import { FileObject, FileOptions, SearchOptions, FetchParameters } from '../lib/types' +import { + FileObject, + FileOptions, + SearchOptions, + FetchParameters, + TransformOptions, +} from '../lib/types' const DEFAULT_SEARCH_OPTIONS = { limit: 100, @@ -259,11 +265,12 @@ export default class StorageFileApi { * @param path The file path, including the current file name. For example `folder/image.png`. * @param expiresIn The number of seconds until the signed URL expires. For example, `60` for a URL which is valid for one minute. * @param options.download triggers the file as a download if set to true. Set this parameter as the name of the file if you want to trigger the download with a different filename. + * @param options.transform Transform the asset before serving it to the client. */ async createSignedUrl( path: string, expiresIn: number, - options?: { download: string | boolean } + options?: { download?: string | boolean; transform?: TransformOptions } ): Promise< | { data: { signedUrl: string } @@ -275,11 +282,12 @@ export default class StorageFileApi { } > { try { - const _path = this._getFinalPath(path) + let _path = this._getFinalPath(path) + let data = await post( this.fetch, `${this.url}/object/sign/${_path}`, - { expiresIn }, + { expiresIn, ...(options?.transform ? { transform: options.transform } : {}) }, { headers: this.headers } ) const downloadQueryParam = options?.download @@ -351,9 +359,11 @@ export default class StorageFileApi { * Downloads a file. * * @param path The full path and file name of the file to be downloaded. For example `folder/image.png`. + * @param options.transform Transform the asset before serving it to the client. */ async download( - path: string + path: string, + options?: { transform?: TransformOptions } ): Promise< | { data: Blob @@ -364,9 +374,14 @@ export default class StorageFileApi { error: StorageError } > { + const wantsTransformation = typeof options?.transform !== 'undefined' + const renderPath = wantsTransformation ? 'render/image/authenticated' : 'object' + const transformationQuery = this.transformOptsToQueryString(options?.transform || {}) + const queryString = transformationQuery ? `?${transformationQuery}` : '' + try { const _path = this._getFinalPath(path) - const res = await get(this.fetch, `${this.url}/object/${_path}`, { + const res = await get(this.fetch, `${this.url}/${renderPath}/${_path}${queryString}`, { headers: this.headers, noResolveJson: true, }) @@ -386,19 +401,39 @@ export default class StorageFileApi { * This function does not verify if the bucket is public. If a public URL is created for a bucket which is not public, you will not be able to download the asset. * * @param path The path and name of the file to generate the public URL for. For example `folder/image.png`. - * @param options.download triggers the file as a download if set to true. Set this parameter as the name of the file if you want to trigger the download with a different filename. + * @param options.download Triggers the file as a download if set to true. Set this parameter as the name of the file if you want to trigger the download with a different filename. + * @param options.transform Transform the asset before serving it to the client. */ getPublicUrl( path: string, - options?: { download: string | boolean } + options?: { download?: string | boolean; transform?: TransformOptions } ): { data: { publicUrl: string } } { const _path = this._getFinalPath(path) + const _queryString = [] + const downloadQueryParam = options?.download - ? `?download=${options.download === true ? '' : options.download}` + ? `download=${options.download === true ? '' : options.download}` : '' + if (downloadQueryParam !== '') { + _queryString.push(downloadQueryParam) + } + + const wantsTransformation = typeof options?.transform !== 'undefined' + const renderPath = wantsTransformation ? 'render/image' : 'object' + const transformationQuery = this.transformOptsToQueryString(options?.transform || {}) + + if (transformationQuery !== '') { + _queryString.push(transformationQuery) + } + + let queryString = _queryString.join('&') + if (queryString !== '') { + queryString = `?${queryString}` + } + return { - data: { publicUrl: encodeURI(`${this.url}/object/public/${_path}${downloadQueryParam}`) }, + data: { publicUrl: encodeURI(`${this.url}/${renderPath}/public/${_path}${queryString}`) }, } } @@ -543,4 +578,21 @@ export default class StorageFileApi { private _removeEmptyFolders(path: string) { return path.replace(/^\/|\/$/g, '').replace(/\/+/g, '/') } + + private transformOptsToQueryString(transform: TransformOptions) { + const params = [] + if (transform.width) { + params.push(`width=${transform.width}`) + } + + if (transform.height) { + params.push(`height=${transform.height}`) + } + + if (transform.resize) { + params.push(`resize=${transform.resize}`) + } + + return params.join('&') + } } diff --git a/test/fixtures/upload/sadcat.jpg b/test/fixtures/upload/sadcat.jpg new file mode 100644 index 0000000..859aa4c Binary files /dev/null and b/test/fixtures/upload/sadcat.jpg differ diff --git a/test/storageFileApi.test.ts b/test/storageFileApi.test.ts index 38a633d..1e808c2 100644 --- a/test/storageFileApi.test.ts +++ b/test/storageFileApi.test.ts @@ -3,6 +3,8 @@ import * as fsp from 'fs/promises' import * as fs from 'fs' import * as path from 'path' import FormData from 'form-data' +import assert from 'assert' +import fetch from 'cross-fetch' // TODO: need to setup storage-api server for this test const URL = 'http://localhost:8000/storage/v1' @@ -17,6 +19,17 @@ const newBucket = async (isPublic = true, prefix = '') => { return bucketName } +const findOrCreateBucket = async (name: string, isPublic = true) => { + const { error: bucketNotFound } = await storage.getBucket(name) + + if (bucketNotFound) { + const { error } = await storage.createBucket(name, { public: isPublic }) + expect(error).toBeNull() + } + + return name +} + const uploadFilePath = (fileName: string) => path.resolve(__dirname, 'fixtures', 'upload', fileName) describe('Object API', () => { @@ -25,8 +38,8 @@ describe('Object API', () => { let uploadPath: string beforeEach(async () => { bucketName = await newBucket() - file = await fsp.readFile(uploadFilePath('file.txt')) - uploadPath = `testpath/file-${Date.now()}.txt` + file = await fsp.readFile(uploadFilePath('sadcat.jpg')) + uploadPath = `testpath/file-${Date.now()}.jpg` }) describe('Generate urls', () => { @@ -72,6 +85,20 @@ describe('Object API', () => { expect(res.data?.signedUrl).toContain(`&download=`) }) + test('sign url with transform options', async () => { + await storage.from(bucketName).upload(uploadPath, file) + const res = await storage.from(bucketName).createSignedUrl(uploadPath, 2000, { + download: true, + transform: { + width: 100, + height: 100, + }, + }) + + expect(res.error).toBeNull() + expect(res.data?.signedUrl).toContain(`${URL}/render/image/sign/${bucketName}/${uploadPath}`) + }) + test('sign url with custom filename for download', async () => { await storage.from(bucketName).upload(uploadPath, file) const res = await storage.from(bucketName).createSignedUrl(uploadPath, 2000, { @@ -187,4 +214,58 @@ describe('Object API', () => { ]) }) }) + + describe('Transformations', () => { + it('gets public url with transformation options', () => { + const res = storage.from(bucketName).getPublicUrl(uploadPath, { + transform: { + width: 200, + height: 300, + }, + }) + expect(res.data.publicUrl).toEqual( + `${URL}/render/image/public/${bucketName}/${uploadPath}?width=200&height=300` + ) + }) + + it('will download an authenticated transformed file', async () => { + const privateBucketName = 'my-private-bucket' + await findOrCreateBucket(privateBucketName) + + const { error: uploadError } = await storage.from(privateBucketName).upload(uploadPath, file) + expect(uploadError).toBeNull() + + const res = await storage.from(privateBucketName).download(uploadPath, { + transform: { + width: 200, + height: 200, + }, + }) + + expect(res.error).toBeNull() + expect(res.data?.size).toBeGreaterThan(0) + expect(res.data?.type).toEqual('image/jpeg') + }) + }) + + it('will get a signed transformed image', async () => { + await storage.from(bucketName).upload(uploadPath, file) + const res = await storage.from(bucketName).createSignedUrl(uploadPath, 60000, { + transform: { + width: 200, + height: 200, + }, + }) + + expect(res.error).toBeNull() + assert(res.data) + + const imageResp = await fetch(`${res.data.signedUrl}`) + + expect(parseInt(imageResp.headers.get('content-length') || '')).toBeGreaterThan(0) + expect(imageResp.status).toEqual(200) + expect(imageResp.headers.get('x-transformations')).toEqual( + 'height:200,width:200,resizing_type:fill' + ) + }) })