Skip to content

Commit

Permalink
feat: image transformation (#128) (#129)
Browse files Browse the repository at this point in the history
Co-authored-by: Inian <inian1234@gmail.com>
  • Loading branch information
fenos and inian committed Dec 13, 2022
1 parent 68d4b51 commit 9b7b2df
Show file tree
Hide file tree
Showing 9 changed files with 200 additions and 17 deletions.
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'
)
})
})

0 comments on commit 9b7b2df

Please sign in to comment.