Sunday, January 12, 2020

Upload Files to AWS S3 using Lightning Web Components

Motivation behind this


I was searching for a solution to upload files to AWS S3 from Lightning Web Components (LWC). Through there are few solutions found through either Apex code or through JavaScript but didn't find guided approach to setup AWS S3 account as well as building reusable Lightning Web Components which can be leveraged in any projects. 

Also, base lightning-file-upload component uploads files into Salesforce which cannot be used for this functionality as I want to directly store to AWS.

This idea drives me to come up with a proof-of-concept and sharing the knowledge and challenges with community members.

Let's get started.

Use Case


Business has a requirement not to store files and attachments to Salesforce, rather they have procured AWS S3 account and leverage that storage to store files. It will help Business to reduce storage limit at Salesforce org since that will also comes with cost.

Developer wants to built it with Lightning Web Components and use that anywhere as necessary. From the screen, it will ask user to choose the files or drag and drop files to upload and finally those will be uploaded to AWS S3 bucket.

Developer will also store the file information (e.g. Name, Link to AWS Location etc.) into a custom Attachment object related to the record.

Possible End Results


After building the use case, it will perform the functionality as following video:



Solution Approach


We will create fileUploadLWC component for this functionality.

Flow Diagram


Create a flow diagram like this way to understand the functionalities, flow of control and finalize design approach.



Create AWS Account and S3 Bucket


First, create AWS Account and S3 bucket referring the documentation Create an Amazon S3 Bucket

Here is the following steps I have followed.

Reach following screen and click on Create Bucket


Define Name and Region

Region is important here, initially I chose Asia Pacific (Mumbai) instead of default US East (N. Verginia) which I have faced challenges during testing (refer Solving Pain Points section). Later I changed it to default US East.



Configure Options

Let it be as it is and go next.



Set Permissions

I removed Blocking of all public access.



Review

We can change the settings if required and finally create bucket.



Create Access Key and Access Secret

Since we will be connecting through API from Salesforce so Access key and secret is needed.


After doing this, manually upload a file to verify if everything is okay.


Building Lightning Web Components




For this poc, we will attach file records under Opportunity object. To store file information, create custom Attachment object with following field information:

  • File Name (File_Name__c) Text
  • File URL (FileURL__c) URL
  • Opportunity (Opportunity__c) Lookup(Opportunity)

fileUploadLWC.html will prepare a screen like this:


Code of html as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<template>
    <article class="slds-tile">
    <!-- file upload section -->
    <lightning-card variant="Narrow" title="Lightning Web Component File Uploader for AWS" 
            style="width:30rem"    icon-name="custom:custom14"> 
        <div style="margin-left:5%">
            <div>
                <lightning-input label="" name="file to uploder" onchange={handleSelectedFiles} 
                    type="file" multiple></lightning-input>
            </div><br/>            
            <div class="slds-text-body_small">{fileName}
            <template if:true={showSpinner}>
                <lightning-spinner alternative-text="Uploading the file......" size="medium">                        
                    </lightning-spinner>
            </template>
            </div><br/>
            <div>
                <lightning-button class="slds-m-top--medium" label="Store File to AWS" onclick={handleFileUpload} 
                    variant="brand">
                </lightning-button>
            </div>
        </div><br/><br/>
        <!--displaying uploaded files-->
        <template if:true={tableData}>
            <lightning-card title="Following files uploaded:">
                <div style="width: auto;">                    
                    <ul class="slds-m-around_small">
                        <template for:each={tableData} for:item="attachment">
                            <li key={attachment.Id}>
                                {attachment.File_Name__c}, 
                                <lightning-formatted-url value={attachment.FileURL__c} target="_blank">{attachment.FileURL__c}</lightning-formatted-url>
                            </li>
                        </template>
                    </ul>                    
                </div>
            </lightning-card>
        </template>
    </lightning-card>
    </article>
</template>

fileUploadLWC.js

It performs the operation as given in the flow diagram. Also added context sensitive comments in-line.


  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
/* eslint-disable no-console */
import { LightningElement, api, track } from 'lwc';
import uploadFileToAWS from '@salesforce/apex/AWSFileUploadController.uploadFileToAWS'; 
import displayUploadedFiles from '@salesforce/apex/AWSFileUploadController.displayUploadedFiles';       
import {ShowToastEvent} from 'lightning/platformShowToastEvent';

export default class fileUploadLWC extends LightningElement {
    @api recordId; //get the recordId for which files will be attached.
    selectedFilesToUpload = []; //store selected files
    @track showSpinner = false; //used for when to show spinner
    @track fileName; //to display the selected file name
    @track tableData; //to display the uploaded file and link to AWS
    file; //holding file instance
    myFile;    
    fileType;//holding file type
    fileReaderObj;
    base64FileData;
    

     // get the file name from the user's selection
     handleSelectedFiles(event) {
        if(event.target.files.length > 0) {
            this.selectedFilesToUpload = event.target.files;
            this.fileName = this.selectedFilesToUpload[0].name;
            this.fileType = this.selectedFilesToUpload[0].type;
            console.log('fileName=' + this.fileName);
            console.log('fileType=' + this.fileType);
        }
    }
    
    //parsing the file and prepare for upload.
    handleFileUpload(){
        if(this.selectedFilesToUpload.length > 0) {
            this.showSpinner = true;
            
            this.file = this.selectedFilesToUpload[0];
            //create an intance of File
            this.fileReaderObj = new FileReader();

            //this callback function in for fileReaderObj.readAsDataURL
            this.fileReaderObj.onloadend = (() => {
                //get the uploaded file in base64 format
                let fileContents = this.fileReaderObj.result;
                fileContents = fileContents.substr(fileContents.indexOf(',')+1)
                
                //read the file chunkwise
                let sliceSize = 1024;           
                let byteCharacters = atob(fileContents);
                let bytesLength = byteCharacters.length;
                let slicesCount = Math.ceil(bytesLength / sliceSize);                
                let byteArrays = new Array(slicesCount);
                for (let sliceIndex = 0; sliceIndex < slicesCount; ++sliceIndex) {
                    let begin = sliceIndex * sliceSize;
                    let end = Math.min(begin + sliceSize, bytesLength);                    
                    let bytes = new Array(end - begin);
                    for (let offset = begin, i = 0 ; offset < end; ++i, ++offset) {
                        bytes[i] = byteCharacters[offset].charCodeAt(0);                        
                    }
                    byteArrays[sliceIndex] = new Uint8Array(bytes);                    
                }
                
                //from arraybuffer create a File instance
                this.myFile =  new File(byteArrays, this.fileName, { type: this.fileType });
                
                //callback for final base64 String format
                let reader = new FileReader();
                reader.onloadend = (() => {
                    let base64data = reader.result;
                    this.base64FileData = base64data.substr(base64data.indexOf(',')+1); 
                    this.fileUpload();
                });
                reader.readAsDataURL(this.myFile);                                 
            });
            this.fileReaderObj.readAsDataURL(this.file);            
        }
        else {
            this.fileName = 'Please select a file to upload!';
        }
    }

    //this method calls Apex's controller to upload file in AWS
    fileUpload(){
        
        //implicit call to apex
        uploadFileToAWS({ parentId: this.recordId, 
                        strfileName: this.file.name, 
                        fileType: this.file.type,
                        fileContent: encodeURIComponent(this.base64FileData)})
        .then(result => {
            console.log('Upload result = ' +result);            
            this.fileName = this.fileName + ' - Uploaded Successfully';
            //call to show uploaded files
            this.getUploadedFiles(); 
            this.showSpinner = false;
            // Showing Success message after uploading
            this.dispatchEvent(
                new ShowToastEvent({
                    title: 'Success!!',
                    message: this.file.name + ' - Uploaded Successfully!!!',
                    variant: 'success',
                }),
            );
        })
        .catch(error => {
            // Error to show during upload
            window.console.log(error);
            this.dispatchEvent(
                new ShowToastEvent({
                    title: 'Error in uploading File',
                    message: error.message,
                    variant: 'error',
                }),
            );
            this.showSpinner = false;
        });        
    }

    //retrieve uploaded file information to display to the user
    getUploadedFiles(){
        displayUploadedFiles({parentId: this.recordId})
        .then(data => {
            this.tableData = data;
            console.log('tableData=' + this.tableData);
        })
        .catch(error => {
            this.dispatchEvent(
                new ShowToastEvent({
                    title: 'Error in displaying data!!',
                    message: error.message,
                    variant: 'error',
                }),
            );
        });
    }
}


AWSFileUploadController.cls

uploadFileToAWS method takes file information from js controller and creates end point and post the file as blob with Http callout.


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
public with sharing class AWSFileUploadController {
    
    //This method is used to post file to AWS
    @AuraEnabled
    public static boolean uploadFileToAWS(Id parentId,
                                        String strfileName, String fileType,
                                        String fileContent){
        System.debug('parentId=' + parentId);
        System.debug('strfileName=' + strfileName);
        System.debug('fileType=' + fileType);
        HttpRequest req = new HttpRequest();

        Blob base64Content = EncodingUtil.base64Decode(EncodingUtil.urlDecode(fileContent, 'UTF-8'));
        String attachmentBody =  fileContent;           
        String formattedDateString = Datetime.now().formatGMT('EEE, dd MMM yyyy HH:mm:ss z');
        String dateString = Datetime.now().format('ddMMYYYYHHmmss');        
        String filename = dateString + '_' + parentId + '_' + strfileName;
        
        //AWS specific information
        String key = 'Provide AWS key'; //AWS key
        String secret = 'Provide AWS Secret key'; //AWS Secret key
        String bucketname = 'Provide AWS bucket'; //AWS bucket name
        String host = 's3.amazonaws.com:443'; //'s3.amazonaws.com:443'
       
        String method = 'PUT';
        String endPoint = 'https://' + bucketname + '.' + host + '/'+ filename;

        req.setMethod(method);
        req.setEndpoint(endPoint);

        system.debug('Endpoint='+endPoint);
        //create header information
        req.setHeader('Host', bucketname + '.' + host);
        req.setHeader('Access-Control-Allow-Origin', '*');
        req.setHeader('Content-Length', String.valueOf(attachmentBody.length()));
        req.setHeader('Content-Encoding', 'UTF-8');
        req.setHeader('Content-type', fileType);
        req.setHeader('Connection', 'keep-alive');
        req.setHeader('Date', formattedDateString); 
        req.setHeader('ACL', 'public-read');
        //store file as blob       
        req.setBodyAsBlob(base64Content);
        
        //prepare for signing information
        String stringToSign = 'PUT\n\n' +
        fileType + '\n' +
        formattedDateString + '\n' + '/' + bucketname + '/' + filename;

        String encodedStringToSign = EncodingUtil.urlEncode(stringToSign, 'UTF-8');
        Blob mac = Crypto.generateMac('HMACSHA1', blob.valueof(stringToSign),blob.valueof(secret));
        String signedKey  = EncodingUtil.base64Encode(mac);

        //assign Authorization information
        String authHeader = 'AWS' + ' ' + key + ':' + signedKey;                    
        req.setHeader('Authorization',authHeader);

        //finally send information to AWS        
        Http http = new Http();
        HTTPResponse res = http.send(req);

        System.debug('*Resp:' + String.ValueOF(res.getBody()));
        System.debug('RESPONSE STRING: ' + res.toString());
        System.debug('RESPONSE STATUS: ' + res.getStatus());
        System.debug('STATUS_CODE: ' + res.getStatusCode());

        if(res.getStatusCode() == 200){
            insertAttachmentRecord (parentId,strfileName,endPoint);
            return true;
        }
        return false;
    }

    //This method inserts file information to Custom Attachment object
    public static void insertAttachmentRecord (Id parentId, String fileName, String fileURL){
        Attachment__c attachment = new Attachment__c();
        attachment.Opportunity__c = parentId;
        attachment.FileURL__c = fileURL;
        attachment.File_Name__c =  fileName;
        insert attachment;                                           
    }

    //This method retrieves Attachment record based on OpportunityId
    @AuraEnabled
    public static List<Attachment__c> displayUploadedFiles(Id parentId){
        return [SELECT Id, File_Name__c, FileURL__c FROM Attachment__c
                WHERE Opportunity__c =:parentId];
    }
}

After code is ready, change meta.xml file to expose the component to record detail page.

Since we are making callouts so, endpoint URL must be defined in the Remote Site Settings.





Solving Pain Points


Following issues I have faced and possible solutions:

1. For my AWS account, the region of the bucket was other the default. It may not be activated within 24 hours. So, if we try to make callouts, you might face following Temporary Redirect issues with response status code 307.


To solve this, I have changed the region to US East.

2. If the permission of bucket is restricted then, after posting the file from application if you try to access the link from the web browser then you might face access denied error.

To resolve this, go the bucket policy and check the JSON if mentioned.




Action": [
    "s3:PutObject",
    "s3:PutObjectAcl",
    "s3:GetObject",
    "s3:GetObjectAcl"
 ]

Without GetObjectAcl, it will not allow to access URL hitting from browser.

3. File should be read chunk wise otherwise there will be error in uploading the file.
4. Preparation of signing information to generate signedKey is crucial.

That's all, I have faced.

Finally, in the Opportunity record detail page, add a new tab and expose the fileUploadLWC component and run the application.

Hope, it helps!


Further Reading