Hasta ahora, hemos aprendido a desplegar aplicaciones web en AWS Amplify y a almacenar datos en RDS. En esta sección, aprenderemos a almacenar archivos en Amazon S3.
Amazon Simple Storage Service (Amazon S3) es un servicio de almacenamiento de objetos que ofrece escalabilidad, disponibilidad de datos, seguridad y rendimiento. Amazon S3 es fácil de usar y altamente escalable, lo que lo convierte en una solución ideal para almacenar archivos de cualquier tamaño.
S3 se destaca por varias razones que lo hacen muy popular en el desarrollo de aplicaciones web:
-
Escalabilidad: Con S3, no tienes que preocuparte por quedarte sin espacio. Puedes almacenar desde un par de documentos hasta millones de archivos, sin necesidad de ajustar la infraestructura subyacente.
-
Durabilidad y Alta Disponibilidad: Amazon S3 ofrece una durabilidad del 99.999999999% (11 nueves) para los datos almacenados. Esto significa que tus archivos están extremadamente seguros contra pérdidas. Además, los datos en S3 están disponibles de manera casi inmediata desde cualquier parte del mundo, lo que asegura que tus aplicaciones siempre tengan acceso a la información que necesitan.
-
Seguridad: Los datos en S3 se pueden cifrar tanto en reposo como en tránsito. Además, puedes configurar permisos granulares para controlar quién puede acceder a tus archivos y qué acciones pueden realizar sobre ellos.
-
Costo-efectividad: Solo pagas por el almacenamiento que realmente usas y por el tráfico de datos que generas. Esto lo hace muy accesible para proyectos de todos los tamaños, desde startups hasta grandes empresas.
S3 es extremadamente versátil y se utiliza en una gran variedad de contextos en el desarrollo de aplicaciones web:
-
Alamacenar Archivos Estáticos: S3 es ideal para guardar imágenes, videos, archivos de audio, y otros contenidos estáticos que tus aplicaciones necesitan servir a los usuarios. Por ejemplo, en una red social, las fotos de perfil de los usuarios podrían almacenarse en S3.
-
Backups y Recuperación de Desastres: Muchas organizaciones utilizan S3 para realizar copias de seguridad automáticas de sus bases de datos y aplicaciones. En caso de una falla, los datos se pueden recuperar rápidamente desde S3.
-
Distribución de Contenidos: En combinación con otros servicios de AWS como CloudFront, S3 se puede utilizar para distribuir contenido a nivel global con baja latencia.
-
Big Data y Machine Learning: S3 es frecuentemente utilizado para almacenar grandes volúmenes de datos que luego serán procesados por aplicaciones de análisis de datos o modelos de machine learning.
Imagina que estás desarrollando una aplicación web donde los usuarios pueden subir y descargar archivos, como imágenes de perfil o documentos. El backend de la aplicación, construido con Spring Boot, expone una REST API que el frontend (desarrollado en React) consume para gestionar estos archivos.
El rol de S3 en este caso es crucial: cuando el usuario sube un archivo desde la interfaz de usuario, el archivo se envía al backend a través de un endpoint de la API REST. Desde el backend, el archivo se procesa y luego se almacena de manera segura en un bucket de S3.
Gracias a la API RESTful de S3, tu backend puede manejar la carga y descarga de archivos sin necesidad de almacenar estos archivos localmente en el servidor, lo que reduce la complejidad y mejora la escalabilidad de tu aplicación.
¿Por qué deberías considerar S3 en lugar de guardar archivos directamente en los servidores donde corre tu aplicación?
-
Escalabilidad Automática: A medida que tu aplicación crece, el volumen de datos también crece. S3 se ajusta automáticamente sin necesidad de intervención manual.
-
Reducción de Carga en los Servidores: Al delegar el almacenamiento de archivos a S3, reduces la carga de trabajo en tus servidores principales. Esto se traduce en un mejor rendimiento de tu aplicación y en un uso más eficiente de los recursos.
-
Acceso Global: S3 está diseñado para ofrecer acceso rápido y seguro desde cualquier lugar del mundo. Si tienes usuarios en diferentes regiones, ellos podrán acceder a los archivos sin problemas de latencia significativos.
-
Respaldo y Recuperación Simplificados: Como S3 ofrece durabilidad y respaldo integrado, no necesitas preocuparte por perder archivos en caso de fallos de hardware en tus servidores. Todo está seguro en la nube.
Antes de poder integrar Amazon S3 con nuestra aplicación, necesitamos configurar adecuadamente un bucket S3. Un bucket es un contenedor donde almacenaremos nuestros archivos. A continuación, veremos los pasos clave para crear y configurar un bucket S3.
-
Acceder a la Consola de AWS:
- Primero, inicia sesión en tu cuenta de AWS y navega hasta el servicio S3 desde el panel principal.
- Haz clic en "Create bucket" para iniciar el proceso de creación.
-
Nombre del Bucket:
-
Opciones de Configuración:
- Dejar las configuraciones por defecto y hacer clic en "Create bucket".
-
Obtener las credenciales de acceso:
-
Para interactuar con S3 desde nuestra aplicación, necesitamos obtener las credenciales de acceso. Estas credenciales consisten en un Access Key ID y un Secret Access Key que se utilizan para autenticar las solicitudes a S3. Además es necesario un session token que cambia cada cierto tiempo. Todas estas credenciales se pueden obtener en la sección AWS Details de la consola de AWS academy
-
Una vez hayas obtenido las credenciales en tu sesión de AWS Academy, puedes empezar a implementar el backend del demo. En este demo, hemos implementado una API en Spring Boot que permite a los usuarios cargar, obtener y eliminar su foto de perfil, almacenándola de manera segura en Amazon S3. A continuación, te explico paso a paso cómo funciona cada parte del código, desde la subida de archivos hasta la generación de URLs pre-firmadas para acceder a las fotos de perfil.
- Puedes encontrar el código completo en este repo.
- En esta explicación se asume que ya tienes claro lo básico de Spring Security.
- Es necesario definir las configuraciones para subir archivos en tu aplicación de Spring Boot en el archivo
application.properties
, puedes verlo aquí.
Primero, configuramos la conexión a S3 en nuestra aplicación utilizando las credenciales de AWS y la región correspondiente. Esto se hace en la clase StorageConfig
:
@Configuration
public class StorageConfig {
@Value("${cloud.aws.credentials.accessKey}")
private String accessKey;
@Value("${cloud.aws.credentials.secretKey}")
private String secretKey;
@Value("${cloud.aws.credentials.sessionToken}")
private String accesSessionToken;
@Value("${cloud.aws.region.static}")
private String region;
@Bean
public AmazonS3 getAmazonS3Client() {
final var basicSessionCredentials = new BasicSessionCredentials(accessKey, secretKey, accesSessionToken);
return AmazonS3ClientBuilder
.standard()
.withRegion(String.valueOf(RegionUtils.getRegion(region)))
.withCredentials(new AWSStaticCredentialsProvider(basicSessionCredentials))
.build();
}
}
En este fragmento de código:
- Credenciales Seguras: Las credenciales de AWS (
accessKey
,secretKey
,sessionToken
) se inyectan desde el archivoapplication.properties
, manteniéndolas seguras y fáciles de modificar. Si necesitas una plantilla para tu archivo properties puedes encontrarla aquí, solo recuerda configurar tus variables de entorno. - Cliente de S3: Creamos un
AmazonS3
que es utilizado para interactuar con el servicio de almacenamiento S3.
El siguiente paso es permitir que los usuarios suban su foto de perfil. Esto se maneja en el controlador MediaController
:
@PostMapping("/profile-pic")
public ResponseEntity<?> uploadProfilePic(@RequestBody MultipartFile file, Principal principal) throws Exception {
String username = principal.getName();
String objectKey = "profile-pics/" + username;
String fileKey = storageService.uploadFile(file, objectKey);
UserAccount user = userService.findByEmail(username);
user.setProfilePictureKey(fileKey);
userService.save(user);
return ResponseEntity.ok("Profile picture uploaded successfully");
}
Explicación:
- Seguridad: Utilizamos
Principal
para obtener el nombre del usuario autenticado. Esto asegura que cada usuario solo pueda modificar su propia foto de perfil. - Key Unica en S3: El
objectKey
para almacenar el archivo en S3 se basa en el nombre de usuario, asegurando que cada usuario tenga su propio espacio en S3. - Almacenamiento Seguro: La foto se sube a S3 a través del
StorageService
. El key del objeto se guarda en la base de datos para referencia futura.
El servicio StorageService
maneja la interacción directa con S3, incluyendo la subida de archivos:
public String uploadFile(MultipartFile file, String objectKey) throws Exception {
if (file.isEmpty() || file.getSize() > 5242880)
throw new IllegalArgumentException("Invalid file size");
if (objectKey.startsWith("/") || !objectKey.contains("."))
throw new IllegalArgumentException("Invalid object key");
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(file.getContentType());
metadata.setContentLength(file.getSize());
if (s3Client.doesObjectExist(bucketName, objectKey))
deleteFile(objectKey);
try (InputStream inputStream = file.getInputStream()) {
s3Client.putObject(new PutObjectRequest(bucketName, objectKey, inputStream, metadata));
return objectKey;
} catch (IOException e) {
throw new Exception("Failed to upload file to S3", e);
}
}
Puntos Clave:
- Validación de Archivos: Se valida el tamaño del archivo y el formato del
objectKey
antes de proceder, evitando errores y asegurando que los archivos subidos cumplan con los requisitos. - Metadata del Archivo: Se añaden metadatos como el tipo de contenido y la longitud del archivo, lo que es importante para un manejo correcto en S3.
- Eliminación de Archivos Duplicados: Si ya existe un archivo con el mismo
objectKey
, se elimina antes de subir el nuevo archivo, asegurando que no haya conflictos.
Una URL pre-firmada (Presigned URL) es una URL que proporciona acceso temporal y seguro a un objeto almacenado en Amazon S3, sin necesidad de que el usuario tenga credenciales directas de AWS. La URL pre-firmada está "firmada" con credenciales de AWS y tiene una duración limitada, después de la cual expira y deja de ser válida.
Cuando generas una URL pre-firmada:
- Se firma la URL con tus credenciales de AWS, lo que asegura que solo quienes tengan la URL puedan acceder al archivo.
- Se especifica un tiempo de expiración: Definir el tiempo de validez garantiza que el acceso sea temporal, aumentando la seguridad.
- Se establece el permiso: Puedes definir si la URL permite operaciones de lectura, escritura o ambas.
Por ejemplo, en el contexto de una aplicación web donde los usuarios pueden subir y ver fotos de perfil, una URL pre-firmada te permite:
- Subir una imagen de forma directa y segura al bucket S3.
- Obtener la URL temporal para que el usuario pueda visualizar su imagen de perfil sin exponer el archivo públicamente.
Ahora para obtener la foto de perfil, generamos una URL pre-firmada que permite el acceso temporal al archivo almacenado en S3:
@GetMapping("/profile-pic")
public ResponseEntity<String> getProfilePic(Principal principal) {
UserAccount user = userService.findByEmail(principal.getName());
if (user.getProfilePictureKey() == null)
return ResponseEntity.notFound().build();
String presignedUrl = storageService.generatePresignedUrl(user.getProfilePictureKey());
return ResponseEntity.ok(presignedUrl);
}
public String generatePresignedUrl(String objectKey) {
if (!s3Client.doesObjectExist(bucketName, objectKey))
throw new RuntimeException("File not found in S3");
Date expiration = new Date(System.currentTimeMillis() + 1000 * 60 * 60);
GeneratePresignedUrlRequest generatePresignedUrlRequest =
new GeneratePresignedUrlRequest(bucketName, objectKey)
.withExpiration(expiration);
URL url = s3Client.generatePresignedUrl(generatePresignedUrlRequest);
return url.toString();
}
Detalles Importantes:
- Acceso Seguro: Las URLs pre-firmadas permiten que solo usuarios autenticados puedan acceder a sus fotos de perfil. La URL es válida solo por un tiempo limitado (en este caso, 1 hora).
- Generación Dinámica: La URL se genera al momento de la solicitud, lo que añade una capa adicional de seguridad.
Nuestra API ya es capaz de procesar y almacenar archivos en S3. Ahora, necesitamos una forma de enviar archivos desde el frontend. En este caso, utilizaremos React (typescript) para construir un formulario de carga de archivos y enviarlos a nuestro backend.
Primero, creamos un formulario simple en React que permite a los usuarios seleccionar y cargar un archivo. Pero primero debes de saber que es un form-data.
FormData es una interfaz proporcionada por JavaScript que te permite construir un conjunto de pares clave/valor, representando campos de un formulario, que pueden ser enviados fácilmente utilizando métodos como fetch
o XMLHttpRequest
. Este objeto es particularmente útil cuando necesitas subir archivos junto con otros datos a un servidor.
FormData es extremadamente útil en aplicaciones web donde los usuarios interactúan con formularios que contienen campos de texto y archivos. Algunas situaciones comunes donde se utiliza FormData son:
-
Subida de archivos: Facilita la transmisión de archivos (como imágenes, documentos, etc.) desde el navegador al servidor, permitiendo al usuario adjuntar archivos a un formulario y enviarlos con otros datos.
-
Envío de datos complejos: Cuando un formulario contiene múltiples campos de diferentes tipos (texto, selectores, casillas de verificación, etc.), FormData puede manejar todo ese contenido y enviarlo de una sola vez al servidor.
-
Interacciones asincrónicas: FormData es ideal para aplicaciones modernas que requieren enviar datos al servidor sin recargar la página, mejorando la experiencia del usuario.
El uso de FormData en el frontend es sencillo y se hace en tres pasos principales:
-
Creación del objeto FormData: Puedes crear un objeto FormData vacío o inicializarlo con un formulario HTML existente.
const formData = new FormData(); // Objeto vacío // O inicializar con un formulario HTML const formData = new FormData(document.querySelector('form'));
-
Añadir campos y archivos: Puedes agregar datos al objeto FormData usando el método
.append()
. Este método permite agregar tanto campos de texto como archivos.formData.append('username', 'john_doe'); formData.append('profilePic', fileInput.files[0]); // 'fileInput' es un <input type="file">
-
Enviar el FormData al servidor: Finalmente, el objeto FormData se envía al servidor utilizando
fetch
oXMLHttpRequest
.fetch('/upload', { method: 'POST', body: formData, }) .then(response => response.json()) .then(data => console.log(data)) .catch(error => console.error('Error:', error));
Ahora sí, con esta información en mente, podemos proceder a crear el formulario de carga de archivos en React:
import React, { useState } from 'react';
import axios from 'axios';
const FileUpload = () => {
const [file, setFile] = useState<File | null>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) setFile(selectedFile);
};
const handleUpload = async () => {
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
await axios.post('http://localhost:8080/api/profile-pic', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
alert('File uploaded successfully');
} catch (error) {
alert('Failed to upload file');
}
};
return (
<div>
<input type="file" onChange={handleFileChange} />
<button onClick={handleUpload}>Upload</button>
</div>
);
};
export default FileUpload;
Explicación:
- Estado Local: Utilizamos el estado local de React para almacenar el archivo seleccionado por el usuario.
- Manejo de Eventos: Los eventos
onChange
yonClick
se utilizan para capturar la selección del archivo y la carga del archivo, respectivamente. - Envío de Archivos: Al hacer clic en el botón "Upload", el archivo se envía al backend a través de una solicitud POST.
- Manejo de Errores: Se muestra una alerta en caso de que la carga del archivo falle.
- Nota: Asegúrate de que la URL de la API en
axios.post
coincida con la URL de tu backend.
Finalmente, necesitamos una forma de mostrar la foto de perfil del usuario. Para ello, generamos una URL pre-firmada en el frontend y la utilizamos para cargar la imagen:
import React, { useEffect, useState } from 'react';
import axios from 'axios';
const ProfilePic = () => {
const [profilePicUrl, setProfilePicUrl] = useState<string | null>(null);
useEffect(() => {
const fetchProfilePic = async () => {
try {
const response = await axios.get('http://localhost:8080/api/profile-pic');
setProfilePicUrl(response.data);
} catch (error) {
console.error('Failed to fetch profile picture');
}
};
fetchProfilePic();
}, []);
return (
<div>
{profilePicUrl ? (
<img src={profilePicUrl} alt="Profile Pic" style={{ width: 200, height: 200 }} />
) : (
<p>No profile picture found</p>
)}
</div>
);
};
export default ProfilePic;
Explicación:
- Efecto de Lado del Cliente: Utilizamos un efecto de lado del cliente para cargar la foto de perfil del usuario al renderizar el componente.
- Solicitud GET: Realizamos una solicitud GET a la API para obtener la URL pre-firmada de la foto de perfil.
- Visualización de la Imagen: Si la URL es válida, mostramos la imagen en el componente. De lo contrario, mostramos un mensaje de error.