Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: image transformation (#128) #129

Merged
merged 4 commits into from Dec 13, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Expand Up @@ -7,6 +7,7 @@ on:
- main
- next
- rc
- next-rc-1
workflow_dispatch:

jobs:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Expand Up @@ -6,6 +6,7 @@ on:
- main
- next
- rc
- next-rc-1
workflow_dispatch:

jobs:
Expand Down
33 changes: 29 additions & 4 deletions infra/docker-compose.yml
Expand Up @@ -17,20 +17,22 @@ services:
ports:
- '3000:3000'
depends_on:
- db
storage:
condition: service_healthy
restart: always
environment:
PGRST_DB_URI: postgres://postgres:postgres@db:5432/postgres
PGRST_DB_SCHEMA: public, storage
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
Expand All @@ -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
Expand All @@ -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:
4 changes: 3 additions & 1 deletion infra/postgres/dummy-data.sql
Expand Up @@ -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');
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');
3 changes: 3 additions & 0 deletions infra/storage/Dockerfile
@@ -0,0 +1,3 @@
FROM supabase/storage-api:v0.25.1

RUN apk add curl --no-cache
18 changes: 18 additions & 0 deletions src/lib/types.ts
Expand Up @@ -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'
}
72 changes: 62 additions & 10 deletions 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,
Expand Down Expand Up @@ -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 }
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
})
Expand All @@ -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}`) },
}
}

Expand Down Expand Up @@ -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('&')
}
}
Binary file added test/fixtures/upload/sadcat.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
85 changes: 83 additions & 2 deletions test/storageFileApi.test.ts
Expand Up @@ -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'
Expand All @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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'
)
})
})