Skip to content

Commit 877206f

Browse files
author
Hein
committed
Add AWS S3 storage option
1 parent 58e5c99 commit 877206f

File tree

11 files changed

+266
-64
lines changed

11 files changed

+266
-64
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
# Replace the credentials below with your Supabase, PowerSync and Expo project details.
22
EXPO_PUBLIC_SUPABASE_URL=https://foo.supabase.co
33
EXPO_PUBLIC_SUPABASE_ANON_KEY=foo
4+
EXPO_PUBLIC_ATTACHMENT_STORAGE_OPTION=supabase # Change this to s3 to use AWS S3 storage for attachments
45
EXPO_PUBLIC_SUPABASE_BUCKET= # Optional. Only required when syncing attachments and using Supabase Storage. See packages/powersync-attachments.
56
EXPO_PUBLIC_POWERSYNC_URL=https://foo.powersync.journeyapps.com
67
EXPO_PUBLIC_EAS_PROJECT_ID=foo # Optional. Only required when using EAS.
8+
EXPO_PUBLIC_AWS_S3_REGION=region
9+
EXPO_PUBLIC_AWS_S3_BUCKET_NAME=bucket_name
10+
EXPO_PUBLIC_AWS_S3_ACCESS_KEY_ID=***
11+
EXPO_PUBLIC_AWS_S3_ACCESS_SECRET_ACCESS_KEY=***

demos/react-native-supabase-todolist/README.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
## Overview
44

5-
Demo app demonstrating use of the [PowerSync SDK for React Native](https://www.npmjs.com/package/@powersync/react-native) together with Supabase.
5+
Demo app demonstrating the use of the [PowerSync SDK for React Native](https://www.npmjs.com/package/@powersync/react-native) together with Supabase for authentication and two attachment storage options:
6+
- Supabase (*default*), or
7+
- [Amazon S3](https://docs.aws.amazon.com/s3/)
68

79
A step-by-step guide on Supabase<>PowerSync integration is available [here](https://docs.powersync.com/integration-guides/supabase).
810
Follow all the steps until, but not including, [Test Everything (Using Our Demo App)](https://docs.powersync.com/integration-guides/supabase-+-powersync#test-everything-using-our-demo-app).
@@ -30,6 +32,41 @@ cp .env .env.local
3032

3133
And then edit `.env.local` to insert your credentials for Supabase.
3234

35+
## Attachment storage options
36+
37+
To configure attachment storage, you can choose between Supabase and AWS S3.
38+
In the `.env.local` file, set `EXPO_PUBLIC_ATTACHMENT_STORAGE_OPTION` to either `supabase` or `s3` to configure the attachment storage option.
39+
40+
### AWS S3 Setup
41+
42+
> **_NOTE:_** This guide assumes that you have an AWS account.
43+
44+
To enable attachment storage using AWS S3, set up an S3 bucket by following these steps:
45+
#### Create an S3 Bucket:
46+
47+
- Go to the [S3 Console](https://s3.console.aws.amazon.com/s3) and click `Create bucket`.
48+
- Enter a unique bucket name and select your preferred region.
49+
- Under Object Ownership, set ACLs disabled and ensure the bucket is private.
50+
- Enable Bucket Versioning if you need to track changes to files (optional).
51+
52+
#### Configure Permissions:
53+
54+
Go to the Permissions tab and set up the following:
55+
- A **bucket policy** for access control:
56+
- Click Bucket policy and enter a policy allowing the necessary actions
57+
(e.g., s3:PutObject, s3:GetObject) for the specific users or roles.
58+
- _**(Optional)**_ Configure CORS (Cross-Origin Resource Sharing) if your app requires it
59+
60+
#### Create IAM User for Access
61+
62+
- Go to the [IAM Console](https://console.aws.amazon.com/iam) and create a new user with programmatic access.
63+
- Attach an AmazonS3FullAccess policy to this user, or create a custom policy with specific permissions for the bucket.
64+
- Save the Access Key ID and Secret Access Key.
65+
66+
In your `.env.local` file, add your AWS credentials and S3 bucket name.
67+
68+
## Run app
69+
3370
Run on iOS
3471

3572
```sh

demos/react-native-supabase-todolist/library/powersync/system.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,32 @@ import '@azure/core-asynciterator-polyfill';
22

33
import { PowerSyncDatabase } from '@powersync/react-native';
44
import React from 'react';
5-
import { SupabaseStorageAdapter } from '../storage/SupabaseStorageAdapter';
6-
7-
import { type AttachmentRecord } from '@powersync/attachments';
5+
import S3 from 'aws-sdk/clients/s3';
6+
import { type AttachmentRecord, StorageAdapter } from '@powersync/attachments';
87
import Logger from 'js-logger';
98
import { KVStorage } from '../storage/KVStorage';
109
import { AppConfig } from '../supabase/AppConfig';
1110
import { SupabaseConnector } from '../supabase/SupabaseConnector';
1211
import { AppSchema } from './AppSchema';
1312
import { PhotoAttachmentQueue } from './PhotoAttachmentQueue';
13+
import { createClient } from '@supabase/supabase-js';
14+
import { SupabaseStorageAdapter } from '../storage/SupabaseStorageAdapter';
15+
import { AWSConfig } from '../storage/AWSConfig';
16+
import { AWSStorageAdapter } from '../storage/AWSStorageAdapter';
1417

1518
Logger.useDefaults();
1619

1720
export class System {
1821
kvStorage: KVStorage;
19-
storage: SupabaseStorageAdapter;
22+
storage: StorageAdapter;
2023
supabaseConnector: SupabaseConnector;
2124
powersync: PowerSyncDatabase;
2225
attachmentQueue: PhotoAttachmentQueue | undefined = undefined;
2326

2427
constructor() {
2528
this.kvStorage = new KVStorage();
2629
this.supabaseConnector = new SupabaseConnector(this);
27-
this.storage = this.supabaseConnector.storage;
30+
this.storage = getStorageAdapter(this);
2831
this.powersync = new PowerSyncDatabase({
2932
schema: AppSchema,
3033
database: {
@@ -44,7 +47,7 @@ export class System {
4447
* this.powersync = new PowerSyncDatabase({ database: factory, schema: AppSchema });
4548
*/
4649

47-
if (AppConfig.supabaseBucket) {
50+
if (AppConfig.supabaseBucket || AppConfig.s3bucketName) {
4851
this.attachmentQueue = new PhotoAttachmentQueue({
4952
powersync: this.powersync,
5053
storage: this.storage,
@@ -71,6 +74,31 @@ export class System {
7174
}
7275
}
7376

77+
function getStorageAdapter(system: System): StorageAdapter {
78+
const storageProvider = AppConfig.storageOption;
79+
80+
if (storageProvider === 'supabase') {
81+
const client = createClient(AppConfig.supabaseUrl, AppConfig.supabaseAnonKey, {
82+
auth: {
83+
persistSession: true,
84+
storage: system.kvStorage
85+
}
86+
});
87+
return new SupabaseStorageAdapter({ client: client });
88+
} else if (storageProvider === 's3') {
89+
const s3Client = new S3({
90+
region: AWSConfig.region,
91+
credentials: {
92+
accessKeyId: AWSConfig.accessKeyId,
93+
secretAccessKey: AWSConfig.secretAccessKey
94+
}
95+
});
96+
return new AWSStorageAdapter({ client: s3Client });
97+
} else {
98+
throw new Error('Invalid storage provider specified in STORAGE_PROVIDER');
99+
}
100+
}
101+
74102
export const system = new System();
75103

76104
export const SystemContext = React.createContext(system);
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export const AWSConfig = {
2+
region: process.env.EXPO_PUBLIC_AWS_S3_REGION,
3+
accessKeyId: process.env.EXPO_PUBLIC_AWS_S3_ACCESS_KEY_ID || '',
4+
secretAccessKey: process.env.EXPO_PUBLIC_AWS_S3_ACCESS_SECRET_ACCESS_KEY || '',
5+
bucketName: process.env.EXPO_PUBLIC_AWS_S3_BUCKET_NAME || ''
6+
};
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import * as FileSystem from 'expo-file-system';
2+
import { AWSConfig } from './AWSConfig';
3+
import S3 from 'aws-sdk/clients/s3';
4+
import { BaseStorageAdapter } from './BaseStorageAdapter';
5+
6+
export interface S3StorageAdapterOptions {
7+
client: S3;
8+
}
9+
10+
export class AWSStorageAdapter extends BaseStorageAdapter {
11+
constructor(private options: S3StorageAdapterOptions) {
12+
super();
13+
}
14+
15+
async uploadFile(
16+
filename: string,
17+
data: ArrayBuffer,
18+
options?: {
19+
mediaType?: string;
20+
}
21+
): Promise<void> {
22+
if (!AWSConfig.bucketName) {
23+
throw new Error('AWS S3 bucket not configured in AppConfig.ts');
24+
}
25+
26+
try {
27+
const body = Uint8Array.from(new Uint8Array(data));
28+
const params = {
29+
Bucket: AWSConfig.bucketName,
30+
Key: filename,
31+
Body: body,
32+
ContentType: options?.mediaType
33+
};
34+
35+
await this.options.client.upload(params).promise();
36+
console.log(`File uploaded successfully to ${AWSConfig.bucketName}/${filename}`);
37+
} catch (error) {
38+
console.error('Error uploading file:', error);
39+
throw error;
40+
}
41+
}
42+
43+
async downloadFile(filePath: string): Promise<Blob> {
44+
const s3 = new S3({
45+
region: AWSConfig.region,
46+
accessKeyId: AWSConfig.accessKeyId,
47+
secretAccessKey: AWSConfig.secretAccessKey
48+
});
49+
50+
const params = {
51+
Bucket: AWSConfig.bucketName,
52+
Key: filePath
53+
};
54+
55+
try {
56+
const obj = await s3.getObject(params).promise();
57+
if (obj.Body) {
58+
const data = await new Response(obj.Body as ReadableStream).arrayBuffer();
59+
return new Blob([data]);
60+
} else {
61+
throw new Error('Object body is undefined. Could not download file.');
62+
}
63+
} catch (error) {
64+
console.error('Error downloading file:', error);
65+
throw error;
66+
}
67+
}
68+
69+
async deleteFile(uri: string, options?: { filename?: string }): Promise<void> {
70+
if (await this.fileExists(uri)) {
71+
await FileSystem.deleteAsync(uri);
72+
}
73+
74+
const { filename } = options ?? {};
75+
if (!filename) {
76+
return;
77+
}
78+
79+
if (!AWSConfig.bucketName) {
80+
throw new Error('Supabase bucket not configured in AppConfig.ts');
81+
}
82+
83+
try {
84+
const params = {
85+
Bucket: AWSConfig.bucketName,
86+
Key: filename
87+
};
88+
await this.options.client.deleteObject(params).promise();
89+
console.log(`${filename} deleted successfully from ${AWSConfig.bucketName}.`);
90+
} catch (error) {
91+
console.error(`Error deleting ${filename} from ${AWSConfig.bucketName}:`, error);
92+
}
93+
}
94+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { StorageAdapter } from '@powersync/attachments';
2+
import * as FileSystem from 'expo-file-system';
3+
import { decode as decodeBase64 } from 'base64-arraybuffer';
4+
5+
export abstract class BaseStorageAdapter implements StorageAdapter {
6+
async readFile(
7+
fileURI: string,
8+
options?: { encoding?: FileSystem.EncodingType; mediaType?: string }
9+
): Promise<ArrayBuffer> {
10+
const { encoding = FileSystem.EncodingType.UTF8 } = options ?? {};
11+
const { exists } = await FileSystem.getInfoAsync(fileURI);
12+
if (!exists) {
13+
throw new Error(`File does not exist: ${fileURI}`);
14+
}
15+
const fileContent = await FileSystem.readAsStringAsync(fileURI, options);
16+
if (encoding === FileSystem.EncodingType.Base64) {
17+
return this.base64ToArrayBuffer(fileContent);
18+
}
19+
return this.stringToArrayBuffer(fileContent);
20+
}
21+
22+
async writeFile(
23+
fileURI: string,
24+
base64Data: string,
25+
options?: {
26+
encoding?: FileSystem.EncodingType;
27+
}
28+
): Promise<void> {
29+
const { encoding = FileSystem.EncodingType.UTF8 } = options ?? {};
30+
await FileSystem.writeAsStringAsync(fileURI, base64Data, { encoding });
31+
}
32+
33+
async fileExists(fileURI: string): Promise<boolean> {
34+
const { exists } = await FileSystem.getInfoAsync(fileURI);
35+
return exists;
36+
}
37+
38+
async makeDir(uri: string): Promise<void> {
39+
const { exists } = await FileSystem.getInfoAsync(uri);
40+
if (!exists) {
41+
await FileSystem.makeDirectoryAsync(uri, { intermediates: true });
42+
}
43+
}
44+
45+
async copyFile(sourceUri: string, targetUri: string): Promise<void> {
46+
await FileSystem.copyAsync({ from: sourceUri, to: targetUri });
47+
}
48+
49+
getUserStorageDirectory(): string {
50+
return FileSystem.documentDirectory!;
51+
}
52+
53+
async stringToArrayBuffer(str: string): Promise<ArrayBuffer> {
54+
const encoder = new TextEncoder();
55+
return encoder.encode(str).buffer;
56+
}
57+
58+
/**
59+
* Converts a base64 string to an ArrayBuffer
60+
*/
61+
async base64ToArrayBuffer(base64: string): Promise<ArrayBuffer> {
62+
return decodeBase64(base64);
63+
}
64+
65+
abstract uploadFile(filePath: string, data: ArrayBuffer, options?: { mediaType?: string }): Promise<void>;
66+
abstract downloadFile(filePath: string): Promise<Blob>;
67+
abstract deleteFile(uri: string, options?: { filename?: string }): Promise<void>;
68+
}

demos/react-native-supabase-todolist/library/storage/SupabaseStorageAdapter.ts

Lines changed: 5 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import { SupabaseClient } from '@supabase/supabase-js';
2-
import { decode as decodeBase64 } from 'base64-arraybuffer';
32
import * as FileSystem from 'expo-file-system';
43
import { AppConfig } from '../supabase/AppConfig';
5-
import { StorageAdapter } from '@powersync/attachments';
4+
import { BaseStorageAdapter } from './BaseStorageAdapter';
65

76
export interface SupabaseStorageAdapterOptions {
87
client: SupabaseClient;
98
}
109

11-
export class SupabaseStorageAdapter implements StorageAdapter {
12-
constructor(private options: SupabaseStorageAdapterOptions) {}
10+
export class SupabaseStorageAdapter extends BaseStorageAdapter {
11+
constructor(private options: SupabaseStorageAdapterOptions) {
12+
super();
13+
}
1314

1415
async uploadFile(
1516
filename: string,
@@ -55,21 +56,6 @@ export class SupabaseStorageAdapter implements StorageAdapter {
5556
const { encoding = FileSystem.EncodingType.UTF8 } = options ?? {};
5657
await FileSystem.writeAsStringAsync(fileURI, base64Data, { encoding });
5758
}
58-
async readFile(
59-
fileURI: string,
60-
options?: { encoding?: FileSystem.EncodingType; mediaType?: string }
61-
): Promise<ArrayBuffer> {
62-
const { encoding = FileSystem.EncodingType.UTF8 } = options ?? {};
63-
const { exists } = await FileSystem.getInfoAsync(fileURI);
64-
if (!exists) {
65-
throw new Error(`File does not exist: ${fileURI}`);
66-
}
67-
const fileContent = await FileSystem.readAsStringAsync(fileURI, options);
68-
if (encoding === FileSystem.EncodingType.Base64) {
69-
return this.base64ToArrayBuffer(fileContent);
70-
}
71-
return this.stringToArrayBuffer(fileContent);
72-
}
7359

7460
async deleteFile(uri: string, options?: { filename?: string }): Promise<void> {
7561
if (await this.fileExists(uri)) {
@@ -93,36 +79,4 @@ export class SupabaseStorageAdapter implements StorageAdapter {
9379

9480
console.debug('Deleted file from storage', data);
9581
}
96-
97-
async fileExists(fileURI: string): Promise<boolean> {
98-
const { exists } = await FileSystem.getInfoAsync(fileURI);
99-
return exists;
100-
}
101-
102-
async makeDir(uri: string): Promise<void> {
103-
const { exists } = await FileSystem.getInfoAsync(uri);
104-
if (!exists) {
105-
await FileSystem.makeDirectoryAsync(uri, { intermediates: true });
106-
}
107-
}
108-
109-
async copyFile(sourceUri: string, targetUri: string): Promise<void> {
110-
await FileSystem.copyAsync({ from: sourceUri, to: targetUri });
111-
}
112-
113-
getUserStorageDirectory(): string {
114-
return FileSystem.documentDirectory!;
115-
}
116-
117-
async stringToArrayBuffer(str: string): Promise<ArrayBuffer> {
118-
const encoder = new TextEncoder();
119-
return encoder.encode(str).buffer;
120-
}
121-
122-
/**
123-
* Converts a base64 string to an ArrayBuffer
124-
*/
125-
async base64ToArrayBuffer(base64: string): Promise<ArrayBuffer> {
126-
return decodeBase64(base64);
127-
}
12882
}

0 commit comments

Comments
 (0)