Skip to content

Commit 9b7b2df

Browse files
fenosinian
andauthored
feat: image transformation (#128) (#129)
Co-authored-by: Inian <[email protected]>
1 parent 68d4b51 commit 9b7b2df

File tree

9 files changed

+200
-17
lines changed

9 files changed

+200
-17
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ on:
77
- main
88
- next
99
- rc
10+
- next-rc-1
1011
workflow_dispatch:
1112

1213
jobs:

.github/workflows/release.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ on:
66
- main
77
- next
88
- rc
9+
- next-rc-1
910
workflow_dispatch:
1011

1112
jobs:

infra/docker-compose.yml

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,22 @@ services:
1717
ports:
1818
- '3000:3000'
1919
depends_on:
20-
- db
20+
storage:
21+
condition: service_healthy
2122
restart: always
2223
environment:
2324
PGRST_DB_URI: postgres://postgres:postgres@db:5432/postgres
2425
PGRST_DB_SCHEMA: public, storage
2526
PGRST_DB_ANON_ROLE: postgres
2627
PGRST_JWT_SECRET: super-secret-jwt-token-with-at-least-32-characters-long
2728
storage:
28-
image: supabase/storage-api:v0.20.2
29+
build:
30+
context: ./storage
2931
ports:
3032
- '5000:5000'
3133
depends_on:
32-
- db
33-
- rest
34+
db:
35+
condition: service_healthy
3436
restart: always
3537
environment:
3638
ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYxMzUzMTk4NSwiZXhwIjoxOTI5MTA3OTg1fQ.ReNhHIoXIOa-8tL1DO3e26mJmOTnYuvdgobwIYGzrLQ
@@ -47,6 +49,12 @@ services:
4749
FILE_SIZE_LIMIT: 52428800
4850
STORAGE_BACKEND: file
4951
FILE_STORAGE_BACKEND_PATH: /tmp/storage
52+
ENABLE_IMAGE_TRANSFORMATION: "true"
53+
IMGPROXY_URL: http://imgproxy:8080
54+
volumes:
55+
- assets-volume:/tmp/storage
56+
healthcheck:
57+
test: ['CMD-SHELL', 'curl -f -LI http://localhost:5000/status']
5058
db:
5159
build:
5260
context: ./postgres
@@ -61,3 +69,20 @@ services:
6169
POSTGRES_USER: postgres
6270
POSTGRES_PASSWORD: postgres
6371
POSTGRES_PORT: 5432
72+
healthcheck:
73+
test: [ "CMD-SHELL", "pg_isready" ]
74+
interval: 10s
75+
timeout: 5s
76+
retries: 5
77+
78+
imgproxy:
79+
image: darthsim/imgproxy
80+
ports:
81+
- 50020:8080
82+
volumes:
83+
- assets-volume:/tmp/storage
84+
environment:
85+
- IMGPROXY_LOCAL_FILESYSTEM_ROOT=/
86+
- IMGPROXY_USE_ETAG=true
87+
volumes:
88+
assets-volume:

infra/postgres/dummy-data.sql

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,6 @@ CREATE POLICY authenticated_folder ON storage.objects for all USING (bucket_id='
5151
-- allow CRUD access to a folder in bucket2 to its owners
5252
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());
5353
-- allow CRUD access to bucket4
54-
CREATE POLICY open_all_update ON storage.objects for all WITH CHECK (bucket_id='bucket4');
54+
CREATE POLICY open_all_update ON storage.objects for all WITH CHECK (bucket_id='bucket4');
55+
56+
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');

infra/storage/Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
FROM supabase/storage-api:v0.25.1
2+
3+
RUN apk add curl --no-cache

src/lib/types.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,21 @@ export interface FetchParameters {
7272
export interface Metadata {
7373
name: string
7474
}
75+
76+
export interface TransformOptions {
77+
/**
78+
* The width of the image in pixels.
79+
*/
80+
width?: number
81+
/**
82+
* The height of the image in pixels.
83+
*/
84+
height?: number
85+
/**
86+
* The resize mode can be cover, contain or fill. Defaults to cover.
87+
* Cover resizes the image to maintain it's aspect ratio while filling the entire width and height.
88+
* Contain resizes the image to maintain it's aspect ratio while fitting the entire image within the width and height.
89+
* 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.
90+
*/
91+
resize?: 'cover' | 'contain' | 'fill'
92+
}

src/packages/StorageFileApi.ts

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { isStorageError, StorageError } from '../lib/errors'
22
import { Fetch, get, post, remove } from '../lib/fetch'
33
import { resolveFetch } from '../lib/helpers'
4-
import { FileObject, FileOptions, SearchOptions, FetchParameters } from '../lib/types'
4+
import {
5+
FileObject,
6+
FileOptions,
7+
SearchOptions,
8+
FetchParameters,
9+
TransformOptions,
10+
} from '../lib/types'
511

612
const DEFAULT_SEARCH_OPTIONS = {
713
limit: 100,
@@ -259,11 +265,12 @@ export default class StorageFileApi {
259265
* @param path The file path, including the current file name. For example `folder/image.png`.
260266
* @param expiresIn The number of seconds until the signed URL expires. For example, `60` for a URL which is valid for one minute.
261267
* @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.
268+
* @param options.transform Transform the asset before serving it to the client.
262269
*/
263270
async createSignedUrl(
264271
path: string,
265272
expiresIn: number,
266-
options?: { download: string | boolean }
273+
options?: { download?: string | boolean; transform?: TransformOptions }
267274
): Promise<
268275
| {
269276
data: { signedUrl: string }
@@ -275,11 +282,12 @@ export default class StorageFileApi {
275282
}
276283
> {
277284
try {
278-
const _path = this._getFinalPath(path)
285+
let _path = this._getFinalPath(path)
286+
279287
let data = await post(
280288
this.fetch,
281289
`${this.url}/object/sign/${_path}`,
282-
{ expiresIn },
290+
{ expiresIn, ...(options?.transform ? { transform: options.transform } : {}) },
283291
{ headers: this.headers }
284292
)
285293
const downloadQueryParam = options?.download
@@ -351,9 +359,11 @@ export default class StorageFileApi {
351359
* Downloads a file.
352360
*
353361
* @param path The full path and file name of the file to be downloaded. For example `folder/image.png`.
362+
* @param options.transform Transform the asset before serving it to the client.
354363
*/
355364
async download(
356-
path: string
365+
path: string,
366+
options?: { transform?: TransformOptions }
357367
): Promise<
358368
| {
359369
data: Blob
@@ -364,9 +374,14 @@ export default class StorageFileApi {
364374
error: StorageError
365375
}
366376
> {
377+
const wantsTransformation = typeof options?.transform !== 'undefined'
378+
const renderPath = wantsTransformation ? 'render/image/authenticated' : 'object'
379+
const transformationQuery = this.transformOptsToQueryString(options?.transform || {})
380+
const queryString = transformationQuery ? `?${transformationQuery}` : ''
381+
367382
try {
368383
const _path = this._getFinalPath(path)
369-
const res = await get(this.fetch, `${this.url}/object/${_path}`, {
384+
const res = await get(this.fetch, `${this.url}/${renderPath}/${_path}${queryString}`, {
370385
headers: this.headers,
371386
noResolveJson: true,
372387
})
@@ -386,19 +401,39 @@ export default class StorageFileApi {
386401
* 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.
387402
*
388403
* @param path The path and name of the file to generate the public URL for. For example `folder/image.png`.
389-
* @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.
404+
* @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.
405+
* @param options.transform Transform the asset before serving it to the client.
390406
*/
391407
getPublicUrl(
392408
path: string,
393-
options?: { download: string | boolean }
409+
options?: { download?: string | boolean; transform?: TransformOptions }
394410
): { data: { publicUrl: string } } {
395411
const _path = this._getFinalPath(path)
412+
const _queryString = []
413+
396414
const downloadQueryParam = options?.download
397-
? `?download=${options.download === true ? '' : options.download}`
415+
? `download=${options.download === true ? '' : options.download}`
398416
: ''
399417

418+
if (downloadQueryParam !== '') {
419+
_queryString.push(downloadQueryParam)
420+
}
421+
422+
const wantsTransformation = typeof options?.transform !== 'undefined'
423+
const renderPath = wantsTransformation ? 'render/image' : 'object'
424+
const transformationQuery = this.transformOptsToQueryString(options?.transform || {})
425+
426+
if (transformationQuery !== '') {
427+
_queryString.push(transformationQuery)
428+
}
429+
430+
let queryString = _queryString.join('&')
431+
if (queryString !== '') {
432+
queryString = `?${queryString}`
433+
}
434+
400435
return {
401-
data: { publicUrl: encodeURI(`${this.url}/object/public/${_path}${downloadQueryParam}`) },
436+
data: { publicUrl: encodeURI(`${this.url}/${renderPath}/public/${_path}${queryString}`) },
402437
}
403438
}
404439

@@ -543,4 +578,21 @@ export default class StorageFileApi {
543578
private _removeEmptyFolders(path: string) {
544579
return path.replace(/^\/|\/$/g, '').replace(/\/+/g, '/')
545580
}
581+
582+
private transformOptsToQueryString(transform: TransformOptions) {
583+
const params = []
584+
if (transform.width) {
585+
params.push(`width=${transform.width}`)
586+
}
587+
588+
if (transform.height) {
589+
params.push(`height=${transform.height}`)
590+
}
591+
592+
if (transform.resize) {
593+
params.push(`resize=${transform.resize}`)
594+
}
595+
596+
return params.join('&')
597+
}
546598
}

test/fixtures/upload/sadcat.jpg

28.8 KB
Loading

test/storageFileApi.test.ts

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import * as fsp from 'fs/promises'
33
import * as fs from 'fs'
44
import * as path from 'path'
55
import FormData from 'form-data'
6+
import assert from 'assert'
7+
import fetch from 'cross-fetch'
68

79
// TODO: need to setup storage-api server for this test
810
const URL = 'http://localhost:8000/storage/v1'
@@ -17,6 +19,17 @@ const newBucket = async (isPublic = true, prefix = '') => {
1719
return bucketName
1820
}
1921

22+
const findOrCreateBucket = async (name: string, isPublic = true) => {
23+
const { error: bucketNotFound } = await storage.getBucket(name)
24+
25+
if (bucketNotFound) {
26+
const { error } = await storage.createBucket(name, { public: isPublic })
27+
expect(error).toBeNull()
28+
}
29+
30+
return name
31+
}
32+
2033
const uploadFilePath = (fileName: string) => path.resolve(__dirname, 'fixtures', 'upload', fileName)
2134

2235
describe('Object API', () => {
@@ -25,8 +38,8 @@ describe('Object API', () => {
2538
let uploadPath: string
2639
beforeEach(async () => {
2740
bucketName = await newBucket()
28-
file = await fsp.readFile(uploadFilePath('file.txt'))
29-
uploadPath = `testpath/file-${Date.now()}.txt`
41+
file = await fsp.readFile(uploadFilePath('sadcat.jpg'))
42+
uploadPath = `testpath/file-${Date.now()}.jpg`
3043
})
3144

3245
describe('Generate urls', () => {
@@ -72,6 +85,20 @@ describe('Object API', () => {
7285
expect(res.data?.signedUrl).toContain(`&download=`)
7386
})
7487

88+
test('sign url with transform options', async () => {
89+
await storage.from(bucketName).upload(uploadPath, file)
90+
const res = await storage.from(bucketName).createSignedUrl(uploadPath, 2000, {
91+
download: true,
92+
transform: {
93+
width: 100,
94+
height: 100,
95+
},
96+
})
97+
98+
expect(res.error).toBeNull()
99+
expect(res.data?.signedUrl).toContain(`${URL}/render/image/sign/${bucketName}/${uploadPath}`)
100+
})
101+
75102
test('sign url with custom filename for download', async () => {
76103
await storage.from(bucketName).upload(uploadPath, file)
77104
const res = await storage.from(bucketName).createSignedUrl(uploadPath, 2000, {
@@ -187,4 +214,58 @@ describe('Object API', () => {
187214
])
188215
})
189216
})
217+
218+
describe('Transformations', () => {
219+
it('gets public url with transformation options', () => {
220+
const res = storage.from(bucketName).getPublicUrl(uploadPath, {
221+
transform: {
222+
width: 200,
223+
height: 300,
224+
},
225+
})
226+
expect(res.data.publicUrl).toEqual(
227+
`${URL}/render/image/public/${bucketName}/${uploadPath}?width=200&height=300`
228+
)
229+
})
230+
231+
it('will download an authenticated transformed file', async () => {
232+
const privateBucketName = 'my-private-bucket'
233+
await findOrCreateBucket(privateBucketName)
234+
235+
const { error: uploadError } = await storage.from(privateBucketName).upload(uploadPath, file)
236+
expect(uploadError).toBeNull()
237+
238+
const res = await storage.from(privateBucketName).download(uploadPath, {
239+
transform: {
240+
width: 200,
241+
height: 200,
242+
},
243+
})
244+
245+
expect(res.error).toBeNull()
246+
expect(res.data?.size).toBeGreaterThan(0)
247+
expect(res.data?.type).toEqual('image/jpeg')
248+
})
249+
})
250+
251+
it('will get a signed transformed image', async () => {
252+
await storage.from(bucketName).upload(uploadPath, file)
253+
const res = await storage.from(bucketName).createSignedUrl(uploadPath, 60000, {
254+
transform: {
255+
width: 200,
256+
height: 200,
257+
},
258+
})
259+
260+
expect(res.error).toBeNull()
261+
assert(res.data)
262+
263+
const imageResp = await fetch(`${res.data.signedUrl}`)
264+
265+
expect(parseInt(imageResp.headers.get('content-length') || '')).toBeGreaterThan(0)
266+
expect(imageResp.status).toEqual(200)
267+
expect(imageResp.headers.get('x-transformations')).toEqual(
268+
'height:200,width:200,resizing_type:fill'
269+
)
270+
})
190271
})

0 commit comments

Comments
 (0)