Skip to content

Commit

Permalink
feat(storage_client): upload signed URL (#495)
Browse files Browse the repository at this point in the history
* feat: update storage test docker to the latest

* feat: add tests for signed URL upload

* feat: adds uploadToSignedUrl method

* add implementation for createSignedUploadUrl and uploadToSignedUrlBinary

* remove uploadToSignedUrlBinary method as the method is not intended to be used in web environment

* remove unnecessary setup in test

* feat: add uploadBinaryToSignedUrl

* create test bucket before signed upload test

* properly implement teardown

* add tests for uploadBinaryToSignedUrl

* add more comments for types

* move the comments to the individual members
  • Loading branch information
dshukertjr committed Jun 7, 2023
1 parent 03fa8be commit f330d19
Show file tree
Hide file tree
Showing 6 changed files with 202 additions and 17 deletions.
13 changes: 0 additions & 13 deletions infra/storage_client/docker-compose.yml
Expand Up @@ -12,19 +12,6 @@ services:
ports:
- 8000:8000/tcp
- 8443:8443/tcp
rest:
image: postgrest/postgrest:latest
ports:
- '3000:3000'
depends_on:
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:
build:
context: ./storage
Expand Down
2 changes: 2 additions & 0 deletions infra/storage_client/postgres/dummy-data.sql
Expand Up @@ -38,6 +38,8 @@ INSERT INTO "storage"."objects" ("id", "bucket_id", "name", "owner", "created_at
-- add policies
-- allows user to CRUD all buckets
CREATE POLICY crud_buckets ON storage.buckets for all USING (auth.uid() = '317eadce-631a-4429-a0bb-f19a7a517b4a');
CREATE POLICY crud_objects ON storage.objects for all USING (auth.uid() = '317eadce-631a-4429-a0bb-f19a7a517b4a');

-- allow public CRUD acccess to the public folder in bucket2
CREATE POLICY crud_public_folder ON storage.objects for all USING (bucket_id='bucket2' and (storage.foldername(name))[1] = 'public');
-- allow public CRUD acccess to a particular file in bucket2
Expand Down
2 changes: 1 addition & 1 deletion infra/storage_client/storage/Dockerfile
@@ -1,3 +1,3 @@
FROM supabase/storage-api:v0.29.1
FROM supabase/storage-api:v0.35.1

RUN apk add curl --no-cache
106 changes: 106 additions & 0 deletions packages/storage_client/lib/src/storage_file_api.dart
Expand Up @@ -21,6 +21,12 @@ class StorageFileApi {
return '$bucketId/$path';
}

String _removeEmptyFolders(String path) {
return path
.replaceAll(RegExp(r'/^\/|\/$/g'), '')
.replaceAll(RegExp(r'/\/+/g'), '/');
}

/// Uploads a file to an existing bucket.
///
/// [path] is the relative file path without the bucket ID. Should be of the
Expand Down Expand Up @@ -91,6 +97,106 @@ class StorageFileApi {
return (response as Map)['Key'] as String;
}

/// Upload a file with a token generated from `createUploadSignedUrl`.
///
/// [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.
///
/// [token] The token generated from `createUploadSignedUrl`
///
/// [file] The body of the file to be stored in the bucket.
Future<String> uploadToSignedUrl(
String path,
String token,
File file, [
FileOptions fileOptions = const FileOptions(),
int? retryAttempts,
StorageRetryController? retryController,
]) async {
assert(retryAttempts == null || retryAttempts >= 0,
'retryAttempts has to be greater or equal to 0');

final cleanPath = _removeEmptyFolders(path);
final _path = _getFinalPath(cleanPath);
var url = Uri.parse('${this.url}/object/upload/sign/$_path');
url = url.replace(queryParameters: {'token': token});

await storageFetch.putFile(
url.toString(),
file,
fileOptions,
retryAttempts: retryAttempts ?? _retryAttempts,
retryController: retryController,
);

return cleanPath;
}

/// Upload a binary file with a token generated from `createUploadSignedUrl`.
///
/// [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.
///
/// [token] The token generated from `createUploadSignedUrl`
///
/// [data] The body of the binary file to be stored in the bucket.
Future<String> uploadBinaryToSignedUrl(
String path,
String token,
Uint8List data, [
FileOptions fileOptions = const FileOptions(),
int? retryAttempts,
StorageRetryController? retryController,
]) async {
assert(retryAttempts == null || retryAttempts >= 0,
'retryAttempts has to be greater or equal to 0');

final cleanPath = _removeEmptyFolders(path);
final _path = _getFinalPath(cleanPath);
var url = Uri.parse('${this.url}/object/upload/sign/$_path');
url = url.replace(queryParameters: {'token': token});

await storageFetch.putBinaryFile(
url.toString(),
data,
fileOptions,
retryAttempts: retryAttempts ?? _retryAttempts,
retryController: retryController,
);

return cleanPath;
}

/// Creates a signed upload URL.
///
/// Signed upload URLs can be used upload files to the bucket without further authentication.
/// They are valid for one minute.
///
/// [path] The file path, including the current file name. For example `folder/image.png`.
Future<SignedUploadURLResponse> createSignedUploadUrl(String path) async {
final finalPath = _getFinalPath(path);

final data = await storageFetch.post(
'$url/object/upload/sign/$finalPath',
{},
options: FetchOptions(headers: headers),
);

final signedUrl = Uri.parse('$url${data['url']}');

final token = signedUrl.queryParameters['token'];

if (token == null || token.isEmpty) {
throw StorageException('No token returned by API');
}

return SignedUploadURLResponse(
signedUrl: signedUrl.toString(),
path: path,
token: token,
);

// return { data: { signedUrl: url.toString(), path, token }, error: null }
}

/// Replaces an existing file at the specified path with a new one.
///
/// [path] is the relative file path without the bucket ID. Should be of the
Expand Down
26 changes: 23 additions & 3 deletions packages/storage_client/lib/src/types.dart
Expand Up @@ -160,10 +160,16 @@ class SortBy {
}

class SignedUrl {
final String? path;
final String? signedUrl;
/// The file path, including the current file name. For example `folder/image.png`.
final String path;

const SignedUrl({this.path, this.signedUrl});
/// Full signed URL of the files.
final String signedUrl;

const SignedUrl({
required this.path,
required this.signedUrl,
});

@override
String toString() => 'SignedUrl(path: $path, signedUrl: $signedUrl)';
Expand Down Expand Up @@ -191,6 +197,20 @@ class SignedUrl {
}
}

class SignedUploadURLResponse extends SignedUrl {
/// Token to be used when uploading files with the `uploadToSignedUrl` method.
final String token;

const SignedUploadURLResponse({
required String signedUrl,
required String path,
required this.token,
}) : super(
signedUrl: signedUrl,
path: path,
);
}

class StorageException implements Exception {
final String message;
final String? error;
Expand Down
70 changes: 70 additions & 0 deletions packages/storage_client/test/client_test.dart
Expand Up @@ -134,6 +134,76 @@ void main() {
expect(response, 'Successfully deleted');
});

group('Signed upload URL', () {
setUpAll(() async {
await findOrCreateBucket(newBucketName);
});

tearDown(() async {
await storage.emptyBucket(newBucketName);
});

tearDownAll(() async {
await storage.deleteBucket(newBucketName);
});

test('sign url for upload', () async {
final response =
await storage.from(newBucketName).createSignedUploadUrl(uploadPath);

expect(response.path, uploadPath);
expect(response.token, isNotNull);
expect(
response.signedUrl,
contains(
'$storageUrl/object/upload/sign/$newBucketName/$uploadPath',
));
});

test('can upload with a signed url', () async {
final response =
await storage.from(newBucketName).createSignedUploadUrl(uploadPath);

final uploadedPath = await storage
.from(newBucketName)
.uploadToSignedUrl(response.path, response.token, file);

expect(uploadedPath, uploadPath);
});

test('can upload a binary file with a signed url', () async {
final response =
await storage.from(newBucketName).createSignedUploadUrl(uploadPath);

final uploadedPath = await storage
.from(newBucketName)
.uploadBinaryToSignedUrl(
response.path, response.token, file.readAsBytesSync());

expect(uploadedPath, uploadPath);
});

test('cannot upload to a signed url twice', () async {
final response =
await storage.from(newBucketName).createSignedUploadUrl(uploadPath);

final uploadedPath = await storage
.from(newBucketName)
.uploadToSignedUrl(response.path, response.token, file);

expect(uploadedPath, uploadPath);
try {
await storage
.from(newBucketName)
.uploadToSignedUrl(response.path, response.token, file);
} on StorageException catch (error) {
expect(error.error, 'Duplicate');
expect(error.message, 'The resource already exists');
expect(error.statusCode, '409');
}
});
});

group('Transformations', () {
setUpAll(() async {
await findOrCreateBucket(newBucketName);
Expand Down

0 comments on commit f330d19

Please sign in to comment.