Upload e Exclusão de Imagens no Spring Boot com Google Cloud Storage

Imagem de capa: 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:

  1. Valida o arquivo (tipo, tamanho, não vazio)
  2. Obtém o usuário autenticado atual
  3. Exclui o avatar existente, se houver
  4. Faz upload do novo arquivo para o storage
  5. Atualiza a URL do avatar no banco de dados

Processo de Exclusão:

  1. Obtém o usuário autenticado atual
  2. Exclui o arquivo do storage, se o avatar existir
  3. Define a URL do avatar como null no 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/json

Configuraçã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=5MB

Usando 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: bridge

Volume 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.json

Essa variável é o padrão oficial do Google Cloud SDK.

Qualquer biblioteca do Google (Java, Node, Python, Go, etc.) faz isso automaticamente:

  1. GOOGLE_APPLICATION_CREDENTIALS
  2. Carrega o JSON de credenciais
  3. Autentica no Google Cloud

Tratamento de Erros

Erros Comuns

  1. Arquivo muito grande: "File size must be less than 5MB"
  2. Formato inválido: "Only JPEG, PNG, GIF, and WebP images are allowed"
  3. Arquivo vazio: "File cannot be empty"
  4. Erro de storage: "Failed to upload file: [details]"
  5. Erro de autenticação: 401 Unauthorized

Considerações de Segurança

  1. Validação de Arquivo: Validação estrita de tipos e tamanhos
  2. Autenticação: Todos os endpoints requerem token JWT válido
  3. Isolamento de Usuário: Usuários só podem gerenciar seus próprios avatares

Referencias

Gostou deste artigo? Compartilhe!