Sube adjuntos a S3 desde tus componentes LWC

Este artículo también está disponible en: English (Inglés)

Ya que el almacenamiento de Salesforce es bastante caro, en este artículo trataremos como poder almacenar ficheros en buckets de S3 desde componentes LWC.

Bucket de S3 y reglas de IAM

Primero que nada, necesitas una cuenta de AWS, un bucket y un usuario de IAM con los permisos suficientes para poder almacenar objetos en S3. En el articulo Salesforce & Serverless 101 explicamos como crear tu cuenta de AWS y tu primer usuario, por lo que aquí ahora crearemos un nuevo bucket de S3.

En la consola de AWS, accede a la sección S3 y haz click en Crear Bucket, será necesario que elijas un nombre y una región de disponibilidad. Por razones de seguridad, mantén el bloqueo de acceso publico, ya que usaremos la API de S3 para acceder a los ficheros. También, dependiendo de las políticas de empresa, puedes utilizar el cifrado ofrecido por AWS.

Creando el bucket de S3

Una vez que nuestro bucket está listo, necesitaremos que nuestra clave API sea capaz de leer y escribir objetos en el bucket, abre la sección de IAM, y elige el usuario con el que accederemos a S3.

Define una nueva política, como la siguiente, para permitir la lectura/escritura en el bucket. No necesitaremos permiso de listado, ya que guardaremos la ruta en nuestros registros de Salesforce.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:DeleteObject"
            ],
            "Resource": [
                "arn:aws:s3:::my-example-bucket/*"
            ]
        }
    ]
}

Salesforce named credentials

Salesforce como plataforma puede gestionar la autenticación contra los servicios de AWS, por lo que no será necesario almacenar secretos en objetos personalizados, para ello utilizaremos las Named Credentials.

Crearemos una nueva Named Credential con la API de Metadatos, pero si lo prefieres puedes utilizar la interfaz de Salesforce para crearlo. Abre Visual Studio Code y crea un nuevo fichero llamada aws_s3_storage.namedCredential-meta.xmlcode>. Cpia el código a continuación reemplazando los valores de access token y secret, la región, y la URL del bucket (Será el nombre de tu bucket acabado en s3.amazonaws.com)

<?xml version="1.0" encoding="UTF-8"?>
<NamedCredential xmlns="<http://soap.sforce.com/2006/04/metadata>">
    <awsAccessKey>your-access-key-here</awsAccessKey>
    <awsAccessSecret>your-secret-here</awsAccessSecret>
    <awsRegion>your-region-here</awsRegion>
    <awsService>s3</awsService>
    <generateAuthorizationHeader>true</generateAuthorizationHeader>
    <endpoint><https://my-example-bucket.s3.amazonaws.com></endpoint>
    <label>AWS S3</label>
    <principalType>NamedUser</principalType>
    <protocol>AwsSv4</protocol>
</NamedCredential>

Aviso: Actualmente hay un error en la documentación de Salesforce. El protocolo está definido erróneamente como AwsSig4code> en lugar de AwsSv4code>. Se debe especificar AwsSv4 para conectar a AWS utilizando la versión V4 de la firma

Configuración del Sitio remoto

Por razones de seguridad, Salesforce no permite conexiones a otros dominios sin especificarlo previamente, por lo que tendremos que añadir a la lista blanca el dominio de S3. Lo haremos con la API de metadatos, a continuación se muestra un ejemplo a guardar con el nombre aws_s3_storage.remoteSite-meta.xmlcode>.

<?xml version="1.0" encoding="UTF-8"?>
<RemoteSiteSetting xmlns="<http://soap.sforce.com/2006/04/metadata>">
    <description>Used for S3 upload callouts</description>
    <disableProtocolSecurity>false</disableProtocolSecurity>
    <isActive>true</isActive>
    <url><https://my-example-bucket.s3.amazonaws.com/></url>
</RemoteSiteSetting>

Cambia la URL con el mismo valor del endpoint de la Named Credential.

El objeto personalizado Attachment

Necesitamos almacenar el nombre de cada fichero subido a S3, si en tu caso solo esperas guardar un único adjunto junto a un registro, puedes guardar la clave de S4 directamente en el mismo objeto con un campo de texto. Si por el contrario necesitas guardar varios ficheros, será imprescindible crear un tipo de objeto que lleve la relación de archivos en S3, con una relacion maestro-detalle al objeto propietario del adjunto.

En este ejemplo, hemos creado un objeto personalizado llamado Attachment__ccode> con una relación maestro-detalle al objeto standard Contactcode> .

Desarrollando el lado de cliente

Añadiendo un input para subir ficheros al componente

Para permitir subidas desde tu componente, necesitaremos añadir un capo de fichero, el cual puedes añadir de la siguiente forma:

<lightning-input label="Upload file" onchange={handleSelectedFile} type="file"></lightning-input>

Con el código HTML previo, verás que aparece un campo de fichero como el siguiente:

Gestionando la subida en el lado de cliente

Aviso: Salesforce solo permite un tamaño máximo de 3Mb por ahora. Existe una idea abierta en la documentación de Salesforce, solicitando que se aumente dicho límite.

Cuando soltamos un fichero sobre el campo, o lo elegimos mediante el botón, se disparará el evento handleSelectedFilecode>, que contiene un parámetro evento con toda la información necesaria para obtener el fichero y subirlo.

handleSelectedFile(event) {
    if(event.target.files.length !== 1) {
		    return;
    }
    this.handleFileUpload(event.target.files[0]);
}

Dentro de la función handledSelectedFilecode> llamamos a otra función denominada handleFileUploadcode>. Esta función es un poco completa, se encarga de convertir nuestro fichero a base64 y enviarlo a nuestra función APEX.

handleFileUpload(file) {
    let fileReaderObj = new FileReader();
    fileReaderObj.onloadend = (() => {
        let fileContents = fileReaderObj.result;
        fileContents = fileContents.substr(fileContents.indexOf(',')+1)
        
        let byteCharacters = atob(fileContents);
        let bytesLength = byteCharacters.length;
        let slicesCount = Math.ceil(bytesLength / 1024);                
        let byteArrays = new Array(slicesCount);
        for (let sliceIndex = 0; sliceIndex < slicesCount; ++sliceIndex) {
            let begin = sliceIndex * 1024;
            let end = Math.min(begin + 1024, 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);                    
        }
        
        let myFile =  new File(byteArrays, file.name, { type: file.type });
        
        let reader = new FileReader();
        reader.onloadend = (async () => {
            await this.fileUpload(
                file.name,
                reader.result.substr(reader.result.indexOf(',')+1)
            );
            try {
                // Call to our APEX function
	        await uploadFileOrFail({
                    parentId: this.recordId,
		    filename: file.name,
		    fileContent: encodeURIComponent(reader.result.substr(reader.result.indexOf(',')+1))
		});
	    } catch (error) {
	        console.log(error);
       	    }
        });

        reader.readAsDataURL(myFile);                                 
    });

    fileReaderObj.readAsDataURL(file);
}

Ahora, el lado de servidor

La clase servicio S3:

Necesitaremos desarrollar en APEX nuestras funciones, y para ser lo más elegante posible, crearemos una clase S3 en la que mantendremos toda la lógica relacionada con la comunicación con AWS. Crea una nueva clase en el fichero S3.clscode> con el siguiente código.

Esto creará una llamada PUT a S3 utilizando las credenciales que previamente hemos almacenado en la Named Credential, para almacenar el contenido.

public with sharing class S3 {
    public static boolean saveFileOrFail(String callout, String bucket, String key, String content) {
        Blob base64Content = EncodingUtil.base64Decode(EncodingUtil.urlDecode(content, 'UTF-8'));

        HttpRequest req = new HttpRequest();
        req.setMethod('PUT');
        req.setEndpoint('callout:' + callout + '/' + key);

        req.setHeader('Host', bucket + '.s3.amazonaws.com');
        req.setHeader('Access-Control-Allow-Origin', '*');
        req.setHeader('Content-Length', String.valueOf(content.length()));
        req.setHeader('Content-Encoding', 'UTF-8');
        req.setHeader('Connection', 'keep-alive');
        req.setBodyAsBlob(base64Content);

        Http http = new Http();
        HTTPResponse res = http.send(req);

        if(res.getStatusCode() != 200) {
            throw new S3UploadErrorException(String.ValueOF(res.getBody()));
        }

        return true;
    }
}

También hemos creado una excepción específica, que se devolverá en el caso de que haya algún error, por lo que crea un fichero llamado S3UploadErrorException.clscode>:

public with sharing class S3UploadErrorException extends Exception {
    
}

Creando el controlador

Ahora podemos hacer uso del servicio S3 desde nuestro controlador, por lo que crea un nuevo fichero llamada AttachmentUploadController.clscode> e implementa la función UploadFileOrFailcode>:

public with sharing class AttachmentUploadController {
    public static final String CALLOUT = 'aws_s3_storage';
    public static final String BUCKET = 'my-example-bucket';
    
    @AuraEnabled
    public static void uploadFileOrFail(Id parentId, String filename, String fileContent) {
        if (filename == null) {
            throw new RuntimeException('Filename is empty');
        }

        String uploadFilename = filename.toLowerCase().trim().replaceAll('[^a-z0-9.\\\\s]+', '').replaceAll('[\\\\s]+', '-');
        String key = '/my-directory/' + uploadFilename;

        S3.saveFileOrFail(CALLOUT, BUCKET, key, fileContent);
	AttachmentRepository.insert(parentId, key);
    }
}

Seguro que has visto que hay una llamada a una clase denominada AttachmentRepository, el método al que llamamos simplemente inserta un nuevo registro del tipo Attachment__ccode>, pero a continuación tienes el código de dicho método:

public static Attachment__c insert(Id contactId, String key) {
    Attachment__c attachment = new Attachment__c();
    attachment.Contact__c = contactId;
    attachment.Key__c = key;
    insert attachment;

    return attachment;
}

¿Qué hemos aprendido?

Ahora estamos listos para gestionar cargas de fichero a un servicio externo (S3 en este ejemplo). A partir de aquí, puedes utilizar cualquier otro servicio para almacenar tus ficheros, o incluso crear peticiones firmadas a cualquier servicio, ya que las Named Credentials también pueden gestionar autenticaciones Oauth y JWT.

En nuestro caso, hemos diseñado un pequeño componente capaz de subir imágenes relacionadas con otro objeto y ordenarlas.

Ya que conocemos el identificador del registro cuando se utiliza en la página de visualización, podemos reutilizarlo donde necesitemos. Adicionalmente utilizamos Imgix como CDN para mostrar miniaturas de dichas imágenes.

¡Si tienes alguna duda adicional, déjala en los comentarios! =)