Skip to content

Commit 8cfd95f

Browse files
fenosinian
andauthored
feat: signed upload url (#158)
Co-authored-by: Inian <[email protected]>
1 parent 2c3fcde commit 8cfd95f

File tree

7 files changed

+182
-43
lines changed

7 files changed

+182
-43
lines changed

infra/docker-compose.yml

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,6 @@ services:
1212
ports:
1313
- 8000:8000/tcp
1414
- 8443:8443/tcp
15-
rest:
16-
image: postgrest/postgrest:latest
17-
ports:
18-
- '3000:3000'
19-
depends_on:
20-
storage:
21-
condition: service_healthy
22-
restart: always
23-
environment:
24-
PGRST_DB_URI: postgres://postgres:postgres@db:5432/postgres
25-
PGRST_DB_SCHEMA: public, storage
26-
PGRST_DB_ANON_ROLE: postgres
27-
PGRST_JWT_SECRET: super-secret-jwt-token-with-at-least-32-characters-long
2815
storage:
2916
build:
3017
context: ./storage
@@ -51,6 +38,7 @@ services:
5138
FILE_STORAGE_BACKEND_PATH: /tmp/storage
5239
ENABLE_IMAGE_TRANSFORMATION: "true"
5340
IMGPROXY_URL: http://imgproxy:8080
41+
DEBUG: "knex:*"
5442
volumes:
5543
- assets-volume:/tmp/storage
5644
healthcheck:

infra/postgres/dummy-data.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ INSERT INTO "storage"."objects" ("id", "bucket_id", "name", "owner", "created_at
3838
-- add policies
3939
-- allows user to CRUD all buckets
4040
CREATE POLICY crud_buckets ON storage.buckets for all USING (auth.uid() = '317eadce-631a-4429-a0bb-f19a7a517b4a');
41+
CREATE POLICY crud_objects ON storage.objects for all USING (auth.uid() = '317eadce-631a-4429-a0bb-f19a7a517b4a');
42+
4143
-- allow public CRUD acccess to the public folder in bucket2
4244
CREATE POLICY crud_public_folder ON storage.objects for all USING (bucket_id='bucket2' and (storage.foldername(name))[1] = 'public');
4345
-- allow public CRUD acccess to a particular file in bucket2

infra/storage/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
FROM supabase/storage-api:v0.29.1
1+
FROM supabase/storage-api:v0.35.1
22

33
RUN apk add curl --no-cache

src/packages/StorageFileApi.ts

Lines changed: 124 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ const DEFAULT_FILE_OPTIONS: FileOptions = {
2424
upsert: false,
2525
}
2626

27+
type FileBody =
28+
| ArrayBuffer
29+
| ArrayBufferView
30+
| Blob
31+
| Buffer
32+
| File
33+
| FormData
34+
| NodeJS.ReadableStream
35+
| ReadableStream<Uint8Array>
36+
| URLSearchParams
37+
| string
38+
2739
export default class StorageFileApi {
2840
protected url: string
2941
protected headers: { [key: string]: string }
@@ -52,17 +64,7 @@ export default class StorageFileApi {
5264
private async uploadOrUpdate(
5365
method: 'POST' | 'PUT',
5466
path: string,
55-
fileBody:
56-
| ArrayBuffer
57-
| ArrayBufferView
58-
| Blob
59-
| Buffer
60-
| File
61-
| FormData
62-
| NodeJS.ReadableStream
63-
| ReadableStream<Uint8Array>
64-
| URLSearchParams
65-
| string,
67+
fileBody: FileBody,
6668
fileOptions?: FileOptions
6769
): Promise<
6870
| {
@@ -101,7 +103,7 @@ export default class StorageFileApi {
101103
method,
102104
body: body as BodyInit,
103105
headers,
104-
...(options?.duplex ? { duplex: options.duplex } : {})
106+
...(options?.duplex ? { duplex: options.duplex } : {}),
105107
})
106108

107109
if (res.ok) {
@@ -130,17 +132,7 @@ export default class StorageFileApi {
130132
*/
131133
async upload(
132134
path: string,
133-
fileBody:
134-
| ArrayBuffer
135-
| ArrayBufferView
136-
| Blob
137-
| Buffer
138-
| File
139-
| FormData
140-
| NodeJS.ReadableStream
141-
| ReadableStream<Uint8Array>
142-
| URLSearchParams
143-
| string,
135+
fileBody: FileBody,
144136
fileOptions?: FileOptions
145137
): Promise<
146138
| {
@@ -155,6 +147,115 @@ export default class StorageFileApi {
155147
return this.uploadOrUpdate('POST', path, fileBody, fileOptions)
156148
}
157149

150+
/**
151+
* Upload a file with a token generated from `createUploadSignedUrl`.
152+
* @param path The file path, including the file name. Should be of the format `folder/subfolder/filename.png`. The bucket must already exist before attempting to upload.
153+
* @param token The token generated from `createUploadSignedUrl`
154+
* @param fileBody The body of the file to be stored in the bucket.
155+
*/
156+
async uploadToSignedUrl(
157+
path: string,
158+
token: string,
159+
fileBody: FileBody,
160+
fileOptions?: FileOptions
161+
) {
162+
const cleanPath = this._removeEmptyFolders(path)
163+
const _path = this._getFinalPath(cleanPath)
164+
165+
const url = new URL(this.url + `/object/upload/sign/${_path}`)
166+
url.searchParams.set('token', token)
167+
168+
try {
169+
let body
170+
const options = { upsert: DEFAULT_FILE_OPTIONS.upsert, ...fileOptions }
171+
const headers: Record<string, string> = {
172+
...this.headers,
173+
...{ 'x-upsert': String(options.upsert as boolean) },
174+
}
175+
176+
if (typeof Blob !== 'undefined' && fileBody instanceof Blob) {
177+
body = new FormData()
178+
body.append('cacheControl', options.cacheControl as string)
179+
body.append('', fileBody)
180+
} else if (typeof FormData !== 'undefined' && fileBody instanceof FormData) {
181+
body = fileBody
182+
body.append('cacheControl', options.cacheControl as string)
183+
} else {
184+
body = fileBody
185+
headers['cache-control'] = `max-age=${options.cacheControl}`
186+
headers['content-type'] = options.contentType as string
187+
}
188+
189+
const res = await this.fetch(url.toString(), {
190+
method: 'PUT',
191+
body: body as BodyInit,
192+
headers,
193+
})
194+
195+
if (res.ok) {
196+
return {
197+
data: { path: cleanPath },
198+
error: null,
199+
}
200+
} else {
201+
const error = await res.json()
202+
return { data: null, error }
203+
}
204+
} catch (error) {
205+
if (isStorageError(error)) {
206+
return { data: null, error }
207+
}
208+
209+
throw error
210+
}
211+
}
212+
213+
/**
214+
* Creates a signed upload URL.
215+
* Signed upload URLs can be used upload files to the bucket without further authentication.
216+
* They are valid for one minute.
217+
* @param path The file path, including the current file name. For example `folder/image.png`.
218+
*/
219+
async createSignedUploadUrl(
220+
path: string
221+
): Promise<
222+
| {
223+
data: { signedUrl: string; token: string; path: string }
224+
error: null
225+
}
226+
| {
227+
data: null
228+
error: StorageError
229+
}
230+
> {
231+
try {
232+
let _path = this._getFinalPath(path)
233+
234+
const data = await post(
235+
this.fetch,
236+
`${this.url}/object/upload/sign/${_path}`,
237+
{},
238+
{ headers: this.headers }
239+
)
240+
241+
const url = new URL(this.url + data.url)
242+
243+
const token = url.searchParams.get('token')
244+
245+
if (!token) {
246+
throw new StorageError('No token returned by API')
247+
}
248+
249+
return { data: { signedUrl: url.toString(), path, token }, error: null }
250+
} catch (error) {
251+
if (isStorageError(error)) {
252+
return { data: null, error }
253+
}
254+
255+
throw error
256+
}
257+
}
258+
158259
/**
159260
* Replaces an existing file at the specified path with a new one.
160261
*

test/__snapshots__/storageApi.test.ts.snap

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,17 @@
33
exports[`bucket api Get bucket by id 1`] = `
44
{
55
"allowed_mime_types": null,
6-
"created_at": "2021-02-17T04:43:32.770206+00:00",
6+
"created_at": "2021-02-17T04:43:32.770Z",
77
"file_size_limit": 0,
88
"id": "bucket2",
99
"name": "bucket2",
1010
"owner": "4d56e902-f0a0-4662-8448-a4d9e643c142",
1111
"public": false,
12-
"updated_at": "2021-02-17T04:43:32.770206+00:00",
12+
"updated_at": "2021-02-17T04:43:32.770Z",
1313
}
1414
`;
1515

16-
exports[`bucket api Get bucket with wrong id 1`] = `[StorageApiError: The resource was not found]`;
16+
exports[`bucket api Get bucket with wrong id 1`] = `[StorageApiError: Bucket not found]`;
1717

1818
exports[`bucket api delete bucket 1`] = `
1919
{

test/storageApi.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { StorageClient } from '../src/index'
33
// TODO: need to setup storage-api server for this test
44
const URL = 'http://localhost:8000/storage/v1'
55
const KEY =
6-
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTYwMzk2ODgzNCwiZXhwIjoyNTUwNjUzNjM0LCJhdWQiOiIiLCJzdWIiOiIzMTdlYWRjZS02MzFhLTQ0MjktYTBiYi1mMTlhN2E1MTdiNGEiLCJSb2xlIjoicG9zdGdyZXMifQ.pZobPtp6gDcX0UbzMmG3FHSlg4m4Q-22tKtGWalOrNo'
6+
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2ODA5NjcxMTUsImV4cCI6MTcxMjUwMzI1MywiYXVkIjoiIiwic3ViIjoiMzE3ZWFkY2UtNjMxYS00NDI5LWEwYmItZjE5YTdhNTE3YjRhIiwicm9sZSI6ImF1dGhlbnRpY2F0ZWQifQ.NNzc54y9cZ2QLUHVSrCPOcGE2E0i8ouldc-AaWLsI08'
77

88
const storage = new StorageClient(URL, { Authorization: `Bearer ${KEY}` })
99
const newBucketName = `my-new-bucket-${Date.now()}`

test/storageFileApi.test.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import fetch from 'cross-fetch'
99
// TODO: need to setup storage-api server for this test
1010
const URL = 'http://localhost:8000/storage/v1'
1111
const KEY =
12-
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTYwMzk2ODgzNCwiZXhwIjoyNTUwNjUzNjM0LCJhdWQiOiIiLCJzdWIiOiIzMTdlYWRjZS02MzFhLTQ0MjktYTBiYi1mMTlhN2E1MTdiNGEiLCJSb2xlIjoicG9zdGdyZXMifQ.pZobPtp6gDcX0UbzMmG3FHSlg4m4Q-22tKtGWalOrNo'
12+
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2ODA5NjcxMTUsImV4cCI6MTcxMjUwMzI1MywiYXVkIjoiIiwic3ViIjoiMzE3ZWFkY2UtNjMxYS00NDI5LWEwYmItZjE5YTdhNTE3YjRhIiwicm9sZSI6ImF1dGhlbnRpY2F0ZWQifQ.NNzc54y9cZ2QLUHVSrCPOcGE2E0i8ouldc-AaWLsI08'
1313

1414
const storage = new StorageClient(URL, { Authorization: `Bearer ${KEY}` })
1515

@@ -67,7 +67,9 @@ describe('Object API', () => {
6767
})
6868

6969
test('sign url', async () => {
70-
await storage.from(bucketName).upload(uploadPath, file)
70+
const uploadRes = await storage.from(bucketName).upload(uploadPath, file)
71+
expect(uploadRes.error).toBeNull()
72+
7173
const res = await storage.from(bucketName).createSignedUrl(uploadPath, 2000)
7274

7375
expect(res.error).toBeNull()
@@ -215,6 +217,52 @@ describe('Object API', () => {
215217
statusCode: '422',
216218
})
217219
})
220+
221+
test('sign url for upload', async () => {
222+
const res = await storage.from(bucketName).createSignedUploadUrl(uploadPath)
223+
224+
expect(res.error).toBeNull()
225+
expect(res.data?.path).toBe(uploadPath)
226+
expect(res.data?.token).toBeDefined()
227+
expect(res.data?.signedUrl).toContain(`${URL}/object/upload/sign/${bucketName}/${uploadPath}`)
228+
})
229+
230+
test('can upload with a signed url', async () => {
231+
const { data, error } = await storage.from(bucketName).createSignedUploadUrl(uploadPath)
232+
233+
expect(error).toBeNull()
234+
assert(data?.path)
235+
236+
const uploadRes = await storage
237+
.from(bucketName)
238+
.uploadToSignedUrl(data.path, data.token, file)
239+
240+
expect(uploadRes.error).toBeNull()
241+
expect(uploadRes.data?.path).toEqual(uploadPath)
242+
})
243+
244+
test('cannot upload to a signed url twice', async () => {
245+
const { data, error } = await storage.from(bucketName).createSignedUploadUrl(uploadPath)
246+
247+
expect(error).toBeNull()
248+
assert(data?.path)
249+
250+
const uploadRes = await storage
251+
.from(bucketName)
252+
.uploadToSignedUrl(data.path, data.token, file)
253+
254+
expect(uploadRes.error).toBeNull()
255+
expect(uploadRes.data?.path).toEqual(uploadPath)
256+
257+
const uploadRes2 = await storage
258+
.from(bucketName)
259+
.uploadToSignedUrl(data.path, data.token, file)
260+
expect(uploadRes2.error).toEqual({
261+
error: 'Duplicate',
262+
message: 'The resource already exists',
263+
statusCode: '409',
264+
})
265+
})
218266
})
219267

220268
describe('File operations', () => {

0 commit comments

Comments
 (0)