Upload e Exclusão de Imagens no Spring Boot com Google Cloud Storage
O sistema de avatar permite que os usuários:
- Carreguem fotos de perfil (formatos JPEG, PNG, GIF, WebP)
- Substituam automaticamente avatares existentes ao enviar novos
- Excluam o avatar atual
O sistema utiliza Google Cloud Storage para armazenamento de arquivos e inclui validação e tratamento de erros adequados.
Antes de começar
Caso precise saber como configurar autenticação com JWT usando Spring Security recomendo assistir: ENTENDENDO O SPRING SECURITY DE UMA VEZ POR TODAS - Matheus Leandro Ferreira
Implementação do Backend
1. Avatar Controller (AvatarController.java)
O controller fornece dois endpoints principais:
@RestController
@RequestMapping("/api/avatar")
public class AvatarController {
private final AvatarService avatarService;
public AvatarController(AvatarService avatarService) {
this.avatarService = avatarService;
}
@PostMapping
public ResponseEntity<String> upload(@RequestParam("file") MultipartFile file) {
String avatarUrl = avatarService.uploadAvatar(file);
return ResponseEntity.ok("Avatar uploaded successfully. URL: " + avatarUrl);
}
@DeleteMapping
public ResponseEntity<String> delete() {
avatarService.deleteAvatar();
return ResponseEntity.ok("Avatar deleted successfully");
}
}2. Avatar Service (AvatarService.java)
O serviço lida com a lógica de negócio:
@Service
@Transactional
public class AvatarService {
private static final List<String> ALLOWED_TYPES = Arrays.asList("image/jpeg", "image/png", "image/gif",
"image/webp");
private static final long MAX_SIZE = 5 * 1024 * 1024; // 5MB
private final StorageService storageService;
private final UserService userService;
private final UserRepository userRepository;
public AvatarService(StorageService storageService, UserService userService, UserRepository userRepository) {
this.storageService = storageService;
this.userService = userService;
this.userRepository = userRepository;
}
public String uploadAvatar(MultipartFile file) throws IOException {
validateFile(file);
User user = userService.getCurrentUserEntity();
if (user.getAvatarUrl() != null) {
storageService.deleteFile(user.getAvatarUrl());
}
String avatarUrl = storageService.uploadFile(file, "avatars/user_" + user.getId());
user.setAvatarUrl(avatarUrl);
userRepository.save(user);
return avatarUrl;
}
public void deleteAvatar() {
User user = userService.getCurrentUserEntity();
if (user.getAvatarUrl() != null) {
storageService.deleteFile(user.getAvatarUrl());
user.setAvatarUrl(null);
userRepository.save(user);
}
}
private void validateFile(MultipartFile file) {
if (file.isEmpty()) {
throw new RuntimeException("File cannot be empty");
}
if (file.getSize() > MAX_SIZE) {
throw new RuntimeException("File size must be less than 5MB");
}
if (!ALLOWED_TYPES.contains(file.getContentType())) {
throw new RuntimeException("Only JPEG, PNG, GIF, and WebP images are allowed");
}
}
}Processo de Upload:
- Valida o arquivo (tipo, tamanho, não vazio)
- Obtém o usuário autenticado atual
- Exclui o avatar existente, se houver
- Faz upload do novo arquivo para o storage
- Atualiza a URL do avatar no banco de dados
Processo de Exclusão:
- Obtém o usuário autenticado atual
- Exclui o arquivo do storage, se o avatar existir
- Define a URL do avatar como
nullno banco de dados
Validação de Arquivo:
- Tamanho máximo: 5MB
- Formatos permitidos: JPEG, PNG, GIF, WebP
- O arquivo não pode estar vazio
3. Storage Service (GcpStorageService.java)
Gerencia operações de Google Cloud Storage:
@Service
public class GcpStorageService implements StorageService {
@Value("${gcp.project-id}")
private String projectId;
@Value("${gcp.bucket-name}")
private String bucketName;
@Override
public String uploadFile(MultipartFile file, String path) throws IOException {
try {
String fileName = generateFileName(file, path);
Storage storage = StorageOptions.newBuilder().setProjectId(projectId).build().getService();
BlobId blobId = BlobId.of(bucketName, fileName);
BlobInfo blobInfo = BlobInfo.newBuilder(blobId).setContentType(file.getContentType()).build();
storage.createFrom(blobInfo, file.getInputStream());
return String.format("https://storage.googleapis.com/%s/%s", bucketName, fileName);
} catch (Exception e) {
throw new IOException("Failed to upload file: " + e.getMessage(), e);
}
}
@Override
public void deleteFile(String fileUrl) {
try {
String fileName = extractFileName(fileUrl);
Storage storage = StorageOptions.newBuilder().setProjectId(projectId).build().getService();
Blob blob = storage.get(bucketName, fileName);
if (blob != null) {
blob.delete();
}
} catch (Exception e) {
throw new RuntimeException("Failed to delete file: " + e.getMessage(), e);
}
}
private String generateFileName(MultipartFile file, String path) {
String extension = getFileExtension(file.getOriginalFilename());
return path + "_" + UUID.randomUUID() + extension;
}
private String getFileExtension(String filename) {
return filename != null && filename.contains(".") ?
filename.substring(filename.lastIndexOf(".")) : "";
}
private String extractFileName(String fileUrl) {
return fileUrl.substring(fileUrl.lastIndexOf(bucketName) + bucketName.length() + 1);
}
}
- Gera nomes de arquivos únicos usando UUID
- Armazena arquivos com prefixo de caminho:
avatars/user_{userId}_{uuid}.{extension} - Retorna URLs públicas para acesso imediato
- Trata exclusão de arquivos extraindo o nome do arquivo da URL
4. Entidade Usuário
A entidade User inclui um campo de URL de avatar:
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
private String password;
private String avatarUrl; // Armazena a URL pública do avatar
public String getName() {
return firstName + " " + lastName;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of();
}
@Override
public String getUsername() {
return email;
}
}Se você usa Flyway Migration:
ALTER TABLE users ADD COLUMN avatar_url VARCHAR(255);Endpoints da API
Upload de Avatar
POST /api/avatar
Content-Type: multipart/form-data
Authorization: Bearer {token}
Form Data:
- file: [arquivo de imagem]Resposta:
{
"success": true,
"data": {
"avatarUrl": "https://storage.googleapis.com/bucket/avatars/user_123_uuid.jpg",
"message": "Avatar uploaded successfully"
}
}Exclusão de Avatar
DELETE /api/avatar
Authorization: Bearer {token}Resposta:
{
"success": true,
"data": {
"avatarUrl": null,
"message": "Avatar deleted successfully"
}
}Arquivo .http para testar essa API
Lembre-se de criar um arquivo avatar.png na mesma pasta.
@baseURL=http://localhost:8080/api
@email=john.doe@example.com
@password=password123
### Register a new user
POST {{baseURL}}/auth/register
Content-Type: application/json
{
"name": "John Doe",
"email": "{{email}}",
"password": "{{password}}"
}
### Login user
# @name login
POST {{baseURL}}/auth/login
Content-Type: application/json
{
"email": "{{email}}",
"password": "{{password}}"
}
### Get current user data
GET {{baseURL}}/auth/current
Authorization: Bearer {{login.response.body.data.token}}
Accept: application/json
### Upload avatar (Method 1: Reference file)
POST {{baseURL}}/avatar
Authorization: Bearer {{login.response.body.data.token}}
Content-Type: multipart/form-data; boundary=boundary
--boundary
Content-Disposition: form-data; name="file"; filename="avatar.png"
Content-Type: image/png
< ./test-avatar.png
--boundary--
### Delete avatar
DELETE {{baseURL}}/avatar
Authorization: Bearer {{login.response.body.data.token}}
Accept: application/jsonConfiguração
Configuração do Backend (application.properties)
# Configuração do GCP Storage
gcp.project-id=your-project-id
gcp.bucket-name=your-bucket-name
# Limites de upload de arquivo
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=5MBUsando as Credenciais do Google Cloud no Docker Compose
services:
postgres:
image: postgres:15
environment:
POSTGRES_DB: backend_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
networks:
- app-network
backend:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
volumes:
- ~/.config/gcloud/application_default_credentials.json:/app/gcp-credentials.json:ro
environment:
- GOOGLE_APPLICATION_CREDENTIALS=/app/gcp-credentials.json
depends_on:
postgres:
condition: service_healthy
volumes:
postgres_data:
networks:
app-network:
driver: bridgeVolume com as credenciais:
volumes: - ~/.config/gcloud/application_default_credentials.json:/app/gcp-credentials.json:ro
Isso faz o seguinte:
- Pega as credenciais do Google Cloud da sua máquina
- Normalmente criadas com:
gcloud auth application-default login
Monta esse arquivo dentro do container. O arquivo dentro do container ficará em:
/app/gcp-credentials.json
:ro= read-only (boa prática de segurança)
Nada é copiado para a imagem Docker, só montado em tempo de execução.
Variável de ambiente
environment:
- GOOGLE_APPLICATION_CREDENTIALS=/app/gcp-credentials.jsonEssa variável é o padrão oficial do Google Cloud SDK.
Qualquer biblioteca do Google (Java, Node, Python, Go, etc.) faz isso automaticamente:
- Lê
GOOGLE_APPLICATION_CREDENTIALS - Carrega o JSON de credenciais
- Autentica no Google Cloud
Tratamento de Erros
Erros Comuns
- Arquivo muito grande: "File size must be less than 5MB"
- Formato inválido: "Only JPEG, PNG, GIF, and WebP images are allowed"
- Arquivo vazio: "File cannot be empty"
- Erro de storage: "Failed to upload file: [details]"
- Erro de autenticação: 401 Unauthorized
Considerações de Segurança
- Validação de Arquivo: Validação estrita de tipos e tamanhos
- Autenticação: Todos os endpoints requerem token JWT válido
- Isolamento de Usuário: Usuários só podem gerenciar seus próprios avatares
Referencias
Gostou deste artigo? Compartilhe!