892 lines
22 KiB
Markdown
892 lines
22 KiB
Markdown
# DevOps Guide - SaveInMed
|
|
|
|
## Status (pronto x faltando)
|
|
|
|
**Pronto**
|
|
- Conteúdo descrito neste documento.
|
|
|
|
**Faltando**
|
|
- Confirmar no código o estado real das funcionalidades e atualizar esta seção conforme necessário.
|
|
|
|
---
|
|
|
|
|
|
Guia de operações e infraestrutura do SaveInMed.
|
|
|
|
---
|
|
|
|
## Scaleway Container Registry
|
|
|
|
### 1. Login no Registry
|
|
|
|
```bash
|
|
# Login no registry Scaleway (usar secret do CI/CD)
|
|
echo "${SCW_SECRET_KEY}" | docker login rg.fr-par.scw.cloud -u nologin --password-stdin
|
|
```
|
|
|
|
> [!CAUTION]
|
|
> **NUNCA** commitar credenciais no código. Use variáveis de ambiente ou secrets do CI/CD.
|
|
|
|
### 2. Build da Imagem
|
|
|
|
```bash
|
|
# Build com platform específico (importante para deploy em servidores AMD64)
|
|
docker build --platform linux/amd64 --no-cache -t saveinmed-frontend:latest .
|
|
|
|
# Com BuildKit para cache otimizado
|
|
DOCKER_BUILDKIT=1 docker build \
|
|
--platform linux/amd64 \
|
|
--cache-from rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-frontend:latest \
|
|
-t saveinmed-frontend:latest .
|
|
```
|
|
|
|
### 3. Tag e Push
|
|
|
|
```bash
|
|
# Tag com versão e latest
|
|
docker tag saveinmed-frontend:latest \
|
|
rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-frontend:${VERSION}
|
|
|
|
docker tag saveinmed-frontend:latest \
|
|
rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-frontend:latest
|
|
|
|
# Push ambas as tags
|
|
docker push rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-frontend:${VERSION}
|
|
docker push rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-frontend:latest
|
|
```
|
|
|
|
### 4. Variáveis de Ambiente (CI/CD)
|
|
|
|
```bash
|
|
# Secrets do Forgejo/GitHub Actions
|
|
SCW_SECRET_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # IAM → API Keys
|
|
SCW_REGISTRY_NAMESPACE=funcscwinfrastructure... # Container Registry → Namespace
|
|
```
|
|
|
|
---
|
|
|
|
## Melhores Práticas de Versionamento
|
|
|
|
### Semantic Versioning (SemVer)
|
|
|
|
```
|
|
MAJOR.MINOR.PATCH
|
|
|
|
Exemplo: 1.2.3
|
|
- MAJOR (1): Breaking changes
|
|
- MINOR (2): Novas features (backwards compatible)
|
|
- PATCH (3): Bug fixes
|
|
```
|
|
|
|
### Estratégias de Tag
|
|
|
|
| Tag | Uso | Quando |
|
|
|-----|-----|--------|
|
|
| `latest` | Desenvolvimento | Apenas em dev/staging |
|
|
| `v1.2.3` | Produção | Release oficial |
|
|
| `v1.2.3-rc.1` | Release Candidate | Pré-release para testes |
|
|
| `sha-abc1234` | CI/CD | Cada commit (rastreabilidade) |
|
|
| `main-20241229` | Branch + Data | Builds automáticos |
|
|
|
|
### Exemplo de Workflow CI/CD
|
|
|
|
```yaml
|
|
# .forgejo/workflows/deploy.yaml
|
|
env:
|
|
REGISTRY: rg.fr-par.scw.cloud
|
|
NAMESPACE: ${{ secrets.SCW_REGISTRY_NAMESPACE }}
|
|
|
|
jobs:
|
|
build:
|
|
steps:
|
|
- name: Generate version
|
|
run: |
|
|
# Usar git tag se existir, senão usar SHA
|
|
VERSION=$(git describe --tags --always 2>/dev/null || git rev-parse --short HEAD)
|
|
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
|
|
|
- name: Login to Scaleway Registry
|
|
run: |
|
|
echo "${{ secrets.SCW_SECRET_KEY }}" | \
|
|
docker login $REGISTRY -u nologin --password-stdin
|
|
|
|
- name: Build and Push
|
|
run: |
|
|
docker build --platform linux/amd64 \
|
|
-t $REGISTRY/$NAMESPACE/saveinmed-frontend:$VERSION \
|
|
-t $REGISTRY/$NAMESPACE/saveinmed-frontend:latest .
|
|
|
|
docker push $REGISTRY/$NAMESPACE/saveinmed-frontend:$VERSION
|
|
docker push $REGISTRY/$NAMESPACE/saveinmed-frontend:latest
|
|
```
|
|
|
|
### ✅ Boas Práticas
|
|
|
|
- **Sempre usar tags específicas** em produção (nunca `latest`)
|
|
- **Imagens imutáveis**: uma vez publicada, nunca sobrescrever
|
|
- **Multi-stage builds** para imagens menores
|
|
- **Scan de vulnerabilidades** antes do deploy
|
|
- **Cleanup automático** de imagens antigas no registry
|
|
|
|
---
|
|
|
|
## Deploy no Servidor (apolo)
|
|
|
|
### Pré-requisitos
|
|
|
|
- Servidor: `apolo.rede5.com.br`
|
|
- SO: AlmaLinux com Podman
|
|
- Rede: `web_proxy` (para comunicação com Traefik)
|
|
|
|
### Estrutura de Diretórios
|
|
|
|
```
|
|
/mnt/data/saveinmed/
|
|
├── backend/.env # Variáveis de ambiente backend (Go)
|
|
├── backoffice/.env # Variáveis de ambiente backoffice (NestJS)
|
|
└── marketplace/.env # Variáveis de ambiente marketplace (Vite/React)
|
|
|
|
/etc/containers/systemd/
|
|
├── saveinmed-backend.container
|
|
├── saveinmed-backoffice.container
|
|
└── saveinmed-marketplace.container
|
|
```
|
|
|
|
> [!NOTE]
|
|
> Como as imagens vêm do Scaleway Registry, **não é necessário** clonar o repo no servidor.
|
|
> O código está empacotado na imagem Docker.
|
|
|
|
---
|
|
|
|
### Passo 1: Criar Diretórios e Arquivos .env
|
|
|
|
```bash
|
|
# Conectar no servidor
|
|
ssh root@apolo
|
|
|
|
# Criar estrutura de diretórios
|
|
mkdir -p /mnt/data/saveinmed/{backend,backoffice,marketplace}
|
|
```
|
|
|
|
#### Backend .env (`/mnt/data/saveinmed/backend/.env`)
|
|
|
|
```bash
|
|
# Geral
|
|
ENV=production
|
|
PORT=8080
|
|
TZ=America/Sao_Paulo
|
|
|
|
# Database
|
|
DATABASE_URL=postgresql://USER:PASS@postgres-main:5432/saveinmed?sslmode=disable
|
|
|
|
# Redis
|
|
REDIS_URL=redis://:PASSWORD@redis-saveinmed:6379/0
|
|
|
|
# JWT
|
|
JWT_SECRET=GERAR_SECRET_32_CHARS
|
|
JWT_EXPIRATION=7d
|
|
|
|
# CORS
|
|
CORS_ORIGINS=https://saveinmed.com.br,https://api.saveinmed.com.br
|
|
|
|
# Cookies
|
|
COOKIE_SECRET=GERAR_SECRET
|
|
COOKIE_DOMAIN=saveinmed.com.br
|
|
|
|
# Stripe
|
|
STRIPE_SECRET_KEY=sk_live_xxx
|
|
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
|
|
|
# Scaleway Storage
|
|
SCW_ACCESS_KEY=SCWxxx
|
|
SCW_SECRET_KEY=xxx
|
|
SCW_REGION=fr-par
|
|
SCW_BUCKET_NAME=saveinmed-uploads
|
|
SCW_ENDPOINT=https://s3.fr-par.scw.cloud
|
|
```
|
|
|
|
#### Marketplace .env (`/mnt/data/saveinmed/marketplace/.env`)
|
|
|
|
```bash
|
|
# Vite/React SPA - variáveis são injetadas no build
|
|
# Para produção, configure no Dockerfile ou CI/CD
|
|
VITE_API_URL=https://api.saveinmed.com.br
|
|
```
|
|
|
|
> [!NOTE]
|
|
> O marketplace é uma SPA (Single Page Application) servida pelo nginx.
|
|
> As variáveis `VITE_*` são injetadas em tempo de build, não de runtime.
|
|
|
|
---
|
|
|
|
### Passo 2: Criar Arquivos Quadlet
|
|
|
|
> [!IMPORTANT]
|
|
> Quadlet é o sistema nativo do Podman para gerenciar containers como serviços systemd.
|
|
|
|
#### Backend (`/etc/containers/systemd/saveinmed-backend.container`)
|
|
|
|
```ini
|
|
[Unit]
|
|
Description=SaveInMed Backend
|
|
After=network-online.target postgres-main.service
|
|
|
|
[Container]
|
|
Image=rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-backend:latest
|
|
ContainerName=saveinmed-backend
|
|
|
|
# Variáveis de ambiente
|
|
EnvironmentFile=/mnt/data/saveinmed/backend/.env
|
|
|
|
# Rede interna (Traefik)
|
|
Network=web_proxy
|
|
PublishPort=8080:8080
|
|
|
|
# Labels Traefik
|
|
Label=traefik.enable=true
|
|
Label=traefik.http.routers.saveinmed-backend.rule=Host(`api.saveinmed.com.br`)
|
|
Label=traefik.http.routers.saveinmed-backend.entrypoints=websecure
|
|
Label=traefik.http.routers.saveinmed-backend.tls.certresolver=myresolver
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
#### Marketplace (`/etc/containers/systemd/saveinmed-marketplace.container`)
|
|
|
|
```ini
|
|
[Unit]
|
|
Description=SaveInMed Marketplace (Vite/React SPA)
|
|
After=network-online.target saveinmed-backend.service
|
|
|
|
[Container]
|
|
Image=rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-marketplace:latest
|
|
ContainerName=saveinmed-marketplace
|
|
Pull=never
|
|
|
|
# serve roda na porta 3000
|
|
Network=web_proxy
|
|
PublishPort=3010:3000
|
|
|
|
# Labels Traefik
|
|
Label=traefik.enable=true
|
|
Label=traefik.http.routers.saveinmed-marketplace.rule=Host(`saveinmed.com.br`)
|
|
Label=traefik.http.routers.saveinmed-marketplace.entrypoints=websecure
|
|
Label=traefik.http.routers.saveinmed-marketplace.tls.certresolver=myresolver
|
|
Label=traefik.http.services.saveinmed-marketplace.loadbalancer.server.port=3000
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
#### Backoffice (`/etc/containers/systemd/saveinmed-backoffice.container`)
|
|
|
|
```ini
|
|
[Unit]
|
|
Description=SaveInMed Backoffice
|
|
After=network-online.target saveinmed-backend.service
|
|
|
|
[Container]
|
|
Image=rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-backoffice:latest
|
|
ContainerName=saveinmed-backoffice
|
|
Pull=never
|
|
|
|
EnvironmentFile=/mnt/data/saveinmed/backoffice/.env
|
|
|
|
Network=web_proxy
|
|
PublishPort=3011:3000
|
|
|
|
Label=traefik.enable=true
|
|
Label=traefik.http.routers.saveinmed-backoffice.rule=Host(`backoffice.saveinmed.com.br`)
|
|
Label=traefik.http.routers.saveinmed-backoffice.entrypoints=websecure
|
|
Label=traefik.http.routers.saveinmed-backoffice.tls.certresolver=myresolver
|
|
Label=traefik.http.services.saveinmed-backoffice.loadbalancer.server.port=3000
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
---
|
|
|
|
### Passo 3: Ativar e Iniciar Serviços
|
|
|
|
```bash
|
|
# Recarregar configurações do systemd
|
|
systemctl daemon-reload
|
|
|
|
# Iniciar serviços
|
|
systemctl start saveinmed-backend
|
|
systemctl start saveinmed-backoffice
|
|
systemctl start saveinmed-marketplace
|
|
|
|
# Habilitar para iniciar no boot
|
|
systemctl enable saveinmed-backend
|
|
systemctl enable saveinmed-backoffice
|
|
systemctl enable saveinmed-marketplace
|
|
|
|
# Verificar status
|
|
systemctl status saveinmed-backend
|
|
systemctl status saveinmed-marketplace
|
|
```
|
|
|
|
---
|
|
|
|
### Passo 4: Atualizar Imagem (sem rebuild)
|
|
|
|
Como você não vai fazer build da imagem localmente, o processo de atualização é:
|
|
|
|
```bash
|
|
# 1. Fazer pull da nova imagem
|
|
podman pull rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-backend:latest
|
|
|
|
# 2. Reiniciar o serviço (vai usar a nova imagem)
|
|
systemctl restart saveinmed-backend
|
|
```
|
|
|
|
Ou para todos:
|
|
|
|
```bash
|
|
# Script de atualização
|
|
for svc in backend backoffice marketplace; do
|
|
podman pull rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-${svc}:latest
|
|
systemctl restart saveinmed-${svc}
|
|
done
|
|
```
|
|
|
|
---
|
|
|
|
### Configuração do Traefik
|
|
|
|
O Traefik já está configurado em `/etc/containers/systemd/traefik.container`:
|
|
|
|
```ini
|
|
[Container]
|
|
Image=docker.io/library/traefik:v3.0
|
|
ContainerName=traefik
|
|
Network=web_proxy
|
|
|
|
PublishPort=80:80
|
|
PublishPort=443:443
|
|
|
|
Volume=/run/podman/podman.sock:/run/podman/podman.sock:ro
|
|
Volume=/opt/traefik/letsencrypt:/letsecrypt
|
|
|
|
Exec=--providers.docker=true \
|
|
--providers.docker.endpoint=unix:///run/podman/podman.sock \
|
|
--providers.docker.exposedByDefault=false \
|
|
--entryPoints.web.address=:80 \
|
|
--entryPoints.websecure.address=:443 \
|
|
--certificatesResolvers.myresolver.acme.tlschallenge=true \
|
|
--certificatesResolvers.myresolver.acme.email=seu-email@dominio.com \
|
|
--certificatesResolvers.myresolver.acme.storage=/letsecrypt/acme.json
|
|
```
|
|
|
|
> [!TIP]
|
|
> Os certificados SSL são gerados automaticamente pelo Let's Encrypt via TLS challenge.
|
|
|
|
---
|
|
|
|
### Comandos Úteis
|
|
|
|
```bash
|
|
# Ver containers rodando
|
|
podman ps
|
|
|
|
# Ver logs de um serviço
|
|
journalctl -u saveinmed-backend -f
|
|
|
|
# Reiniciar todos os serviços SaveInMed
|
|
systemctl restart saveinmed-*
|
|
|
|
# Verificar healthcheck
|
|
curl -s https://api.saveinmed.com.br/health | jq
|
|
|
|
# Login no registry antes de pull
|
|
echo "${SCW_SECRET_KEY}" | podman login rg.fr-par.scw.cloud -u nologin --password-stdin
|
|
```
|
|
|
|
---
|
|
|
|
## Como Subir Imagem no Scaleway
|
|
|
|
### Passo a Passo Rápido
|
|
|
|
1. **Obter credenciais** no console Scaleway → IAM → API Keys
|
|
2. **Configurar variáveis de ambiente** (ver abaixo)
|
|
3. **Usar o endpoint de upload** da API
|
|
|
|
### Endpoint de Upload
|
|
|
|
```bash
|
|
# Upload de imagem de produto
|
|
curl -X POST https://api.saveinmed.com.br/v1/products/{productId}/images \
|
|
-H "Authorization: Bearer {token}" \
|
|
-F "image=@/path/to/image.jpg"
|
|
|
|
# Upload de documento KYC
|
|
curl -X POST https://api.saveinmed.com.br/v1/companies/documents \
|
|
-H "Authorization: Bearer {token}" \
|
|
-F "document=@/path/to/license.pdf" \
|
|
-F "type=license"
|
|
```
|
|
|
|
### Response
|
|
|
|
```json
|
|
{
|
|
"url": "https://s3.fr-par.scw.cloud/saveinmed-uploads/products/abc123/main.webp"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Scaleway Object Storage
|
|
|
|
O Scaleway Object Storage é um serviço de armazenamento de objetos compatível com a API S3 da AWS. Usamos este serviço para:
|
|
- Imagens de produtos
|
|
- Documentos KYC (licenças, CNPJs)
|
|
- Fotos de perfil
|
|
- Comprovantes de entrega
|
|
|
|
---
|
|
|
|
## Configuração
|
|
|
|
### 1. Variáveis de Ambiente
|
|
|
|
```bash
|
|
# .env
|
|
SCW_ACCESS_KEY=SCWxxxxxxxxxxxxxxxxx
|
|
SCW_SECRET_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
|
SCW_REGION=fr-par # ou nl-ams, pl-waw
|
|
SCW_BUCKET_NAME=saveinmed-uploads
|
|
SCW_ENDPOINT=https://s3.fr-par.scw.cloud
|
|
```
|
|
|
|
### 2. Estrutura de Buckets
|
|
|
|
```
|
|
saveinmed-uploads/
|
|
├── products/ # Imagens de produtos
|
|
│ └── {product_id}/
|
|
│ ├── main.webp
|
|
│ └── thumb.webp
|
|
├── documents/ # Documentos KYC
|
|
│ └── {company_id}/
|
|
│ ├── cnpj.pdf
|
|
│ └── license.pdf
|
|
├── profiles/ # Fotos de perfil
|
|
│ └── {user_id}.webp
|
|
└── shipments/ # Comprovantes de entrega
|
|
└── {shipment_id}/
|
|
└── proof.webp
|
|
```
|
|
|
|
---
|
|
|
|
## Implementação Backend (Go)
|
|
|
|
### 1. Dependências
|
|
|
|
```bash
|
|
go get github.com/aws/aws-sdk-go-v2/config
|
|
go get github.com/aws/aws-sdk-go-v2/service/s3
|
|
go get github.com/aws/aws-sdk-go-v2/credentials
|
|
```
|
|
|
|
### 2. Cliente S3 (internal/storage/s3.go)
|
|
|
|
```go
|
|
package storage
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/aws"
|
|
"github.com/aws/aws-sdk-go-v2/config"
|
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
|
)
|
|
|
|
type S3Client struct {
|
|
client *s3.Client
|
|
bucketName string
|
|
endpoint string
|
|
}
|
|
|
|
func NewS3Client() (*S3Client, error) {
|
|
region := os.Getenv("SCW_REGION")
|
|
endpoint := os.Getenv("SCW_ENDPOINT")
|
|
accessKey := os.Getenv("SCW_ACCESS_KEY")
|
|
secretKey := os.Getenv("SCW_SECRET_KEY")
|
|
bucketName := os.Getenv("SCW_BUCKET_NAME")
|
|
|
|
resolver := aws.EndpointResolverWithOptionsFunc(
|
|
func(service, region string, options ...interface{}) (aws.Endpoint, error) {
|
|
return aws.Endpoint{URL: endpoint}, nil
|
|
},
|
|
)
|
|
|
|
cfg, err := config.LoadDefaultConfig(context.TODO(),
|
|
config.WithRegion(region),
|
|
config.WithEndpointResolverWithOptions(resolver),
|
|
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load config: %w", err)
|
|
}
|
|
|
|
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
|
|
o.UsePathStyle = true // Required for Scaleway
|
|
})
|
|
|
|
return &S3Client{
|
|
client: client,
|
|
bucketName: bucketName,
|
|
endpoint: endpoint,
|
|
}, nil
|
|
}
|
|
|
|
// Upload envia um arquivo para o bucket
|
|
func (c *S3Client) Upload(ctx context.Context, key string, reader io.Reader, contentType string) (string, error) {
|
|
_, err := c.client.PutObject(ctx, &s3.PutObjectInput{
|
|
Bucket: aws.String(c.bucketName),
|
|
Key: aws.String(key),
|
|
Body: reader,
|
|
ContentType: aws.String(contentType),
|
|
ACL: "public-read", // ou "private" para documentos sensíveis
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to upload: %w", err)
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/%s/%s", c.endpoint, c.bucketName, key)
|
|
return url, nil
|
|
}
|
|
|
|
// GetPresignedURL gera URL temporária para download
|
|
func (c *S3Client) GetPresignedURL(ctx context.Context, key string, expiration time.Duration) (string, error) {
|
|
presigner := s3.NewPresignClient(c.client)
|
|
|
|
req, err := presigner.PresignGetObject(ctx, &s3.GetObjectInput{
|
|
Bucket: aws.String(c.bucketName),
|
|
Key: aws.String(key),
|
|
}, s3.WithPresignExpires(expiration))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return req.URL, nil
|
|
}
|
|
|
|
// Delete remove um arquivo do bucket
|
|
func (c *S3Client) Delete(ctx context.Context, key string) error {
|
|
_, err := c.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
|
Bucket: aws.String(c.bucketName),
|
|
Key: aws.String(key),
|
|
})
|
|
return err
|
|
}
|
|
```
|
|
|
|
### 3. Handler de Upload (internal/http/handler/upload_handler.go)
|
|
|
|
```go
|
|
package handler
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const maxUploadSize = 10 << 20 // 10MB
|
|
|
|
var allowedMimeTypes = map[string]bool{
|
|
"image/jpeg": true,
|
|
"image/png": true,
|
|
"image/webp": true,
|
|
"application/pdf": true,
|
|
}
|
|
|
|
func (h *Handler) UploadProductImage(w http.ResponseWriter, r *http.Request) {
|
|
// Limitar tamanho
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
|
|
|
|
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
|
http.Error(w, "Arquivo muito grande (max 10MB)", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
file, header, err := r.FormFile("image")
|
|
if err != nil {
|
|
http.Error(w, "Arquivo não encontrado", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Validar MIME type
|
|
contentType := header.Header.Get("Content-Type")
|
|
if !allowedMimeTypes[contentType] {
|
|
http.Error(w, "Tipo de arquivo não permitido", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Gerar key única
|
|
productID := r.PathValue("productId")
|
|
ext := filepath.Ext(header.Filename)
|
|
key := fmt.Sprintf("products/%s/%s%s", productID, uuid.New().String(), ext)
|
|
|
|
// Upload
|
|
url, err := h.storage.Upload(r.Context(), key, file, contentType)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"url": url})
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Implementação Frontend (TypeScript)
|
|
|
|
### 1. Serviço de Upload (src/services/storageService.ts)
|
|
|
|
```typescript
|
|
import { apiClient } from './apiClient'
|
|
|
|
interface UploadResponse {
|
|
url: string
|
|
}
|
|
|
|
export const storageService = {
|
|
// Upload de imagem de produto
|
|
uploadProductImage: async (productId: string, file: File): Promise<string> => {
|
|
const formData = new FormData()
|
|
formData.append('image', file)
|
|
|
|
const response = await apiClient.post<UploadResponse>(
|
|
`/v1/products/${productId}/images`,
|
|
formData
|
|
)
|
|
return response.url
|
|
},
|
|
|
|
// Upload de documento KYC
|
|
uploadDocument: async (file: File, type: 'cnpj' | 'license' | 'other'): Promise<string> => {
|
|
const formData = new FormData()
|
|
formData.append('document', file)
|
|
formData.append('type', type)
|
|
|
|
const response = await apiClient.post<UploadResponse>(
|
|
'/v1/companies/documents',
|
|
formData
|
|
)
|
|
return response.url
|
|
},
|
|
|
|
// Upload com preview e progresso
|
|
uploadWithProgress: async (
|
|
endpoint: string,
|
|
file: File,
|
|
onProgress: (percent: number) => void
|
|
): Promise<string> => {
|
|
return new Promise((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest()
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
|
|
xhr.upload.addEventListener('progress', (e) => {
|
|
if (e.lengthComputable) {
|
|
onProgress(Math.round((e.loaded / e.total) * 100))
|
|
}
|
|
})
|
|
|
|
xhr.addEventListener('load', () => {
|
|
if (xhr.status === 200) {
|
|
const response = JSON.parse(xhr.responseText)
|
|
resolve(response.url)
|
|
} else {
|
|
reject(new Error('Upload failed'))
|
|
}
|
|
})
|
|
|
|
xhr.addEventListener('error', () => reject(new Error('Upload failed')))
|
|
|
|
xhr.open('POST', `${import.meta.env.VITE_API_URL}${endpoint}`)
|
|
xhr.setRequestHeader('Authorization', `Bearer ${localStorage.getItem('token')}`)
|
|
xhr.send(formData)
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2. Componente de Upload (src/components/ImageUpload.tsx)
|
|
|
|
```tsx
|
|
import { useState, useRef } from 'react'
|
|
import { storageService } from '../services/storageService'
|
|
|
|
interface ImageUploadProps {
|
|
productId: string
|
|
onUploadComplete: (url: string) => void
|
|
}
|
|
|
|
export function ImageUpload({ productId, onUploadComplete }: ImageUploadProps) {
|
|
const [uploading, setUploading] = useState(false)
|
|
const [progress, setProgress] = useState(0)
|
|
const [preview, setPreview] = useState<string | null>(null)
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
|
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
|
|
// Validações
|
|
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
|
|
alert('Apenas JPEG, PNG ou WebP são permitidos')
|
|
return
|
|
}
|
|
|
|
if (file.size > 10 * 1024 * 1024) {
|
|
alert('Máximo 10MB')
|
|
return
|
|
}
|
|
|
|
// Preview local
|
|
setPreview(URL.createObjectURL(file))
|
|
setUploading(true)
|
|
|
|
try {
|
|
const url = await storageService.uploadWithProgress(
|
|
`/v1/products/${productId}/images`,
|
|
file,
|
|
setProgress
|
|
)
|
|
onUploadComplete(url)
|
|
} catch (err) {
|
|
console.error('Upload failed:', err)
|
|
alert('Falha no upload')
|
|
} finally {
|
|
setUploading(false)
|
|
setProgress(0)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="upload-container">
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp"
|
|
onChange={handleFileSelect}
|
|
hidden
|
|
/>
|
|
|
|
<button
|
|
onClick={() => inputRef.current?.click()}
|
|
disabled={uploading}
|
|
>
|
|
{uploading ? `Enviando ${progress}%` : '📤 Enviar Imagem'}
|
|
</button>
|
|
|
|
{preview && (
|
|
<img src={preview} alt="Preview" className="upload-preview" />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Melhores Práticas
|
|
|
|
### 1. Segurança
|
|
|
|
- ✅ **Validar MIME type** no backend (não confiar no frontend)
|
|
- ✅ **Limitar tamanho** máximo de upload (10MB recomendado)
|
|
- ✅ **Usar URLs presignadas** para documentos sensíveis
|
|
- ✅ **Verificar autenticação** antes de permitir upload
|
|
- ✅ **Sanitizar nomes** de arquivos (usar UUID)
|
|
|
|
### 2. Performance
|
|
|
|
- ✅ **Compressão de imagens** antes do upload (client-side)
|
|
- ✅ **Gerar thumbnails** automaticamente
|
|
- ✅ **Usar WebP** como formato preferencial
|
|
- ✅ **CDN** em produção para distribuição global
|
|
|
|
### 3. Organização
|
|
|
|
```bash
|
|
# Estrutura de keys recomendada
|
|
{bucket}/{tipo}/{entity_id}/{arquivo}
|
|
|
|
# Exemplos:
|
|
products/abc123/main.webp
|
|
documents/company456/license.pdf
|
|
profiles/user789.webp
|
|
```
|
|
|
|
### 4. Custo
|
|
|
|
- ✅ **Lifecycle rules** para limpar arquivos antigos
|
|
- ✅ **Storage class** adequado (Standard vs Glacier-like)
|
|
- ✅ **Monitorar uso** para evitar surpresas
|
|
|
|
---
|
|
|
|
## Configuração do Bucket (Console Scaleway)
|
|
|
|
1. Acesse **Object Storage** no console Scaleway
|
|
2. Clique em **Create a bucket**
|
|
3. Nome: `saveinmed-uploads`
|
|
4. Região: `fr-par` (ou mais próxima)
|
|
5. Visibility: **Private** (acesso via API)
|
|
6. Configure **CORS**:
|
|
|
|
```json
|
|
[
|
|
{
|
|
"AllowedOrigins": ["https://saveinmed.com.br", "http://localhost:5173"],
|
|
"AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
|
|
"AllowedHeaders": ["*"],
|
|
"MaxAgeSeconds": 3600
|
|
}
|
|
]
|
|
```
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Erro: "Access Denied"
|
|
- Verifique as credenciais `SCW_ACCESS_KEY` e `SCW_SECRET_KEY`
|
|
- Confirme que as permissões do bucket estão corretas
|
|
|
|
### Erro: "CORS Policy"
|
|
- Configure CORS no bucket (ver acima)
|
|
- Adicione o domínio do frontend na lista de origens permitidas
|
|
|
|
### Upload lento
|
|
- Use upload multipart para arquivos > 5MB
|
|
- Considere compressão client-side para imagens
|
|
|
|
---
|
|
|
|
## Referências
|
|
|
|
- [Scaleway Object Storage Documentation](https://www.scaleway.com/en/docs/object-storage-feature/)
|
|
- [AWS SDK for Go v2](https://aws.github.io/aws-sdk-go-v2/docs/)
|
|
- [S3-Compatible API Reference](https://www.scaleway.com/en/docs/object-storage-s3-api/)
|