Introduction

The AWS credentials should never be exposed directly on the client - it could expose access to the entire S3 bucket to the user. For this tutorial we have therefore decided to use the following workflow:

  1. Client makes an API call to the app backend, using the client credentials (a Supabase Edge Function).
  2. The backend API has the S3 credentials. It signs a S3 upload/download URL, and returns that to the client.
  3. The client uploads/downloads using the pre-signed S3 URL.

The following updates to the React Native To-Do List demo app are therefore required:

  1. Create Supabase Edge Functions, and
  2. Update the demo app to use the AWS S3 storage adapter

The following pre-requisites are required to complete this tutorial:

  • Clone the To-Do List demo app repo
    • Follow the instructions in the README and ensure that the app runs locally
  • A running PowerSync Service (can be self-hosted)

Steps

The complete client files used in this tutorial can be found below

import * as FileSystem from 'expo-file-system';
import { decode as decodeBase64 } from 'base64-arraybuffer';
import { StorageAdapter } from '@powersync/attachments';
import { AppConfig } from '../supabase/AppConfig';
import { SupabaseClient } from '@supabase/supabase-js';

interface S3Upload {
    message: string;
    uploadUrl: string;
}

interface S3Download {
    message: string;
    downloadUrl: string;
}

interface S3Delete {
    message: string;
}

export class AWSStorageAdapter implements StorageAdapter {
    constructor( public client: SupabaseClient ) {}

    async uploadFile(
        filename: string,
        data: ArrayBuffer,
        options?: {
            mediaType?: string;
        }
    ): Promise<void> {

        const response = await this.client.functions.invoke<S3Upload>('s3-upload', {
            body: {
                fileName: filename,
                mediaType: options?.mediaType
            }
        });

        if (response.error || !response.data) {
            throw new Error(`Failed to reach upload edge function, code=${response.error}`);
        }

        const { uploadUrl } = response.data;
        try {
            const body = new Uint8Array(data);

            const response = await fetch(uploadUrl, {
                method: "PUT",
                headers: {
                   "Content-Length": body.length.toString(),
                    "Content-Type": options?.mediaType,
                },
                body: body,
            });

            console.log(`File: ${filename} uploaded successfully.`);
        } catch (error) {
            console.error('Error uploading file:', error);
            throw error;
        }
    }

    async downloadFile(filePath: string): Promise<Blob> {
        const response = await this.client.functions.invoke<S3Download>('s3-download', {
            body: {
                fileName: filePath
            }
        });

        if (response.error || !response.data) {
           throw new Error(`Failed to reach download edge function, code=${response.error}`);
        }

        const { downloadUrl } = response.data;

        try {
            const downloadResponse = await fetch(downloadUrl, {
                method: "GET",
            });

            return await downloadResponse.blob();
        } catch (error) {
            console.error('Error downloading file:', error);
            throw error;
        }
    }

    async deleteFile(uri: string, options?: { filename?: string }): Promise<void> {
        if (await this.fileExists(uri)) {
            await FileSystem.deleteAsync(uri);
        }

        const { filename } = options ?? {};
        if (!filename) {
            return;
        }

        try {
            const response = await this.client.functions.invoke<S3Delete>('s3-delete', {
                body: {
                    fileName: options?.filename
                }
            });

            if (response.error || !response.data) {
                throw new Error(`Failed to reach delete edge function, code=${response.error}`);
            }

            const { message } = response.data;
            console.log(message);
        } catch (error) {
            console.error(`Error deleting ${filename}:`, error);
        }
    }

    async readFile(
        fileURI: string,
        options?: { encoding?: FileSystem.EncodingType; mediaType?: string }
    ): Promise<ArrayBuffer> {
        const { encoding = FileSystem.EncodingType.UTF8 } = options ?? {};
        const { exists } = await FileSystem.getInfoAsync(fileURI);
        if (!exists) {
           throw new Error(`File does not exist: ${fileURI}`);
        }
        const fileContent = await FileSystem.readAsStringAsync(fileURI, options);
        if (encoding === FileSystem.EncodingType.Base64) {
            return this.base64ToArrayBuffer(fileContent);
        }
        return this.stringToArrayBuffer(fileContent);
    }

    async writeFile(
        fileURI: string,
        base64Data: string,
        options?: {
            encoding?: FileSystem.EncodingType;
        }
    ): Promise<void> {
        const { encoding = FileSystem.EncodingType.UTF8 } = options ?? {};
        await FileSystem.writeAsStringAsync(fileURI, base64Data, { encoding });
    }

    async fileExists(fileURI: string): Promise<boolean> {
        const { exists } = await FileSystem.getInfoAsync(fileURI);
        return exists;
    }

    async makeDir(uri: string): Promise<void> {
        const { exists } = await FileSystem.getInfoAsync(uri);
        if (!exists) {
            await FileSystem.makeDirectoryAsync(uri, { intermediates: true });
        }
    }

    async copyFile(sourceUri: string, targetUri: string): Promise<void> {
        await FileSystem.copyAsync({ from: sourceUri, to: targetUri });
    }

    getUserStorageDirectory(): string {
        return FileSystem.documentDirectory!;
    }

    async stringToArrayBuffer(str: string): Promise<ArrayBuffer> {
        const encoder = new TextEncoder();
        return encoder.encode(str).buffer;
    }

    /**
     * Converts a base64 string to an ArrayBuffer
     */
    async base64ToArrayBuffer(base64: string): Promise<ArrayBuffer> {
        return decodeBase64(base64);
    }
}