Integração: Cloudflare R2 Storage¶
Visão Geral¶
O Cloudflare R2 é utilizado para armazenamento de mídias (imagens, áudios, vídeos, documentos) das conversas e anexos dos agentes. O acesso é feito via SDK AWS S3 (compatível com R2).
Responsabilidades: - Upload de mídias recebidas no WhatsApp - Upload de anexos vinculados a agentes (knowledge base) - Armazenamento organizado por tenant, conversa e agente - Geração de URLs públicas para acesso às mídias e anexos
Configuração¶
Variáveis de Ambiente¶
R2_ACCOUNT_ID=your-cloudflare-account-id
R2_ACCESS_KEY_ID=your-r2-access-key
R2_SECRET_ACCESS_KEY=your-r2-secret-key
R2_BUCKET_NAME=ciba-media
R2_PUBLIC_URL=https://media.yourdomain.com
Options Pattern¶
// R2Options.cs
public class R2Options
{
public const string SectionName = "R2";
public string AccountId { get; set; } = string.Empty;
public string AccessKeyId { get; set; } = string.Empty;
public string SecretAccessKey { get; set; } = string.Empty;
public string BucketName { get; set; } = string.Empty;
public string PublicUrl { get; set; } = string.Empty;
}
appsettings.json¶
{
"R2": {
"AccountId": "your-account-id",
"AccessKeyId": "your-access-key",
"SecretAccessKey": "your-secret-key",
"BucketName": "ciba-media",
"PublicUrl": "https://media.yourdomain.com"
}
}
Registro do Serviço¶
// ServiceCollectionExtensions.cs
services.Configure<R2Options>(configuration.GetSection(R2Options.SectionName));
services.AddSingleton<IAmazonS3>(sp =>
{
var options = sp.GetRequiredService<IOptions<R2Options>>().Value;
var config = new AmazonS3Config
{
ServiceURL = $"https://{options.AccountId}.r2.cloudflarestorage.com",
ForcePathStyle = true
};
return new AmazonS3Client(
options.AccessKeyId,
options.SecretAccessKey,
config);
});
services.AddSingleton<IFileStorageService, R2StorageService>();
IFileStorageService — Abstração¶
public interface IFileStorageService
{
/// Path: conversations/tenants/{tenantId}/{conversationId}/{timestamp}_{shortId}_{fileName}
/// Lifecycle: 90 days.
Task<string> SaveMediaAsync(
byte[] data, string fileName, string mimeType,
Guid tenantId, Guid conversationId,
CancellationToken ct = default);
Task<byte[]?> FetchMediaAsync(string mediaUrl, CancellationToken ct = default);
/// Path: agents/tenants/{tenantId}/{agentId}/attachments/{fileName}
/// Lifecycle: permanent.
Task<string> SaveAttachmentAsync(
byte[] data, string fileName, string mimeType,
Guid tenantId, Guid agentId,
CancellationToken ct = default);
/// Path: playground/tenants/{tenantId}/agents/{agentId}/attachments/{timestamp}_{shortId}_{fileName}
/// Lifecycle: 30 days.
Task<string> SavePlaygroundMediaAsync(
byte[] data, string fileName, string mimeType,
Guid tenantId, Guid agentId,
CancellationToken ct = default);
}
Retorno: URL pública da mídia/anexo salvo.
StoragePath Builder¶
Os paths de storage são construídos via builder fluente (StoragePath), garantindo consistência e evitando strings mágicas.
Arquivo: src/Ciba.Infrastructure/Services/Storage/StoragePath.cs
// Mídias de conversas (90 dias)
StoragePath.Conversations(tenantId).Media(conversationId, fileName);
// Mídias do playground (30 dias)
StoragePath.Playground(tenantId).Attachment(agentId, fileName);
// Anexos permanentes de agentes
StoragePath.Agents(tenantId).Attachment(agentId, fileName);
Estrutura de Pastas¶
Mídias de Conversas¶
conversations/
└── tenants/
└── {tenantId}/
└── {conversationId}/
└── {yyyyMMdd_HHmmss}_{shortId}_{fileName}
Exemplo:
conversations/tenants/a1b2c3d4-5678-90ab-cdef-1234567890ab/e5f6g7h8-.../20240120_143022_9x8y7z6w_foto.jpg
Lifecycle: 90 dias (configurado via lifecycle rule no prefixo conversations/).
Mídias do Playground¶
playground/
└── tenants/
└── {tenantId}/
└── agents/
└── {agentId}/
└── attachments/
└── {yyyyMMdd_HHmmss}_{shortId}_{fileName}
Exemplo:
Lifecycle: 30 dias (configurado via lifecycle rule no prefixo playground/).
Anexos de Agentes (Permanentes)¶
Exemplo:
Lifecycle: Permanente (sem regra de expiração).
Regra de negócio: Não é permitido ter dois anexos com o mesmo FileName para o mesmo agente. A unicidade é validada no UploadAttachmentHandler antes do upload.
Lifecycle Rules (R2/S3)¶
| Prefixo | Expiração | Uso |
|---|---|---|
conversations/ |
90 dias | Mídias de conversas WhatsApp |
playground/ |
30 dias | Mídias temporárias do playground |
agents/ |
Permanente | Anexos da knowledge base |
Quotas de Storage¶
| Limite | Valor | Configuração |
|---|---|---|
| Tamanho máximo por arquivo | 16 MB | AppConfig.MaxAttachmentFileSizeBytes |
| Quota por agente | 500 MB | AppConfig.MaxAttachmentStorageBytesPerAgent |
Benefícios da estrutura: - Prefixo por tipo permite lifecycle rules independentes - Isolamento por tenant (multi-tenancy) - Organização por conversa/agente - Timestamp + shortId garantem unicidade e ordenação cronológica - Builder fluente previne erros de formatação
Fluxo de Upload¶
1. Webhook recebe mensagem com mídia
↓
2. ProcessMessageHandler detecta mídia
↓
3. DownloadMediaAsync() via Evolution API → bytes + mimeType
↓
4. SaveMediaAsync() via R2StorageService
↓
5. Upload para R2 bucket
↓
6. Retorna URL pública
↓
7. Salva URL no campo MediaUrl da Message
Código de Uso¶
// No ProcessMessageHandler
var mediaDownload = await _whatsAppClient.DownloadMediaAsync(
instance,
messageId,
remoteJid,
ct);
if (mediaDownload != null)
{
var bytes = Convert.FromBase64String(mediaDownload.Base64);
var mediaUrl = await _storage.SaveMediaAsync(
bytes,
fileName ?? "media",
mediaDownload.MimeType,
conversation.TenantId,
conversation.Id,
ct);
message.MediaUrl = mediaUrl;
}
Tipos de Mídia Suportados¶
| Tipo | MIME Types Comuns | Extensões |
|---|---|---|
| Imagem | image/jpeg, image/png, image/webp |
.jpg, .png, .webp |
| Vídeo | video/mp4, video/webm |
.mp4, .webm |
| Áudio | audio/ogg, audio/mpeg, audio/webm |
.ogg, .mp3, .webm |
| Documento | application/pdf, application/msword, etc. |
.pdf, .doc, .docx |
| Sticker | image/webp |
.webp |
Sanitização de MIME Type¶
MIME types com parâmetros (ex: audio/webm;codecs=opus) são sanitizados antes do upload:
Sanitização de Nome de Arquivo¶
private static string SanitizeFileName(string fileName)
{
var invalidChars = Path.GetInvalidFileNameChars();
var sanitized = string.Join("", fileName.Split(invalidChars));
if (sanitized.Length > 100)
sanitized = sanitized[..100];
return string.IsNullOrWhiteSpace(sanitized) ? "file" : sanitized;
}
Regras: - Remove caracteres inválidos para nomes de arquivo - Limita a 100 caracteres - Fallback para "file" se vazio
URL Pública¶
A URL pública é construída combinando PublicUrl + key:
var publicUrl = $"{_options.PublicUrl.TrimEnd('/')}/{key}";
// Exemplo: https://media.ciba.com.br/conversations/tenants/a1b2c3d4-.../e5f6g7h8-.../20240120_143022_9x8y7z6w_foto.jpg
Configuração no Cloudflare: - Criar Custom Domain apontando para o bucket R2 - Configurar CORS se necessário - Habilitar cache via Cloudflare CDN
Configurações no AWS SDK¶
var config = new AmazonS3Config
{
ServiceURL = $"https://{options.AccountId}.r2.cloudflarestorage.com",
ForcePathStyle = true // Necessário para R2
};
PutObjectRequest¶
var request = new PutObjectRequest
{
BucketName = _options.BucketName,
Key = key,
InputStream = stream,
ContentType = contentType,
DisablePayloadSigning = true // R2 não suporta payload signing
};
Nota: DisablePayloadSigning = true é obrigatório para compatibilidade com R2.
Health Check¶
public class R2StorageHealthCheck : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken ct)
{
// Verifica conectividade com R2
// Tenta listar objetos ou verificar bucket
}
}
Tratamento de Erros¶
try
{
await _s3.PutObjectAsync(request, ct);
return publicUrl;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to upload media to R2: {Key}", key);
throw;
}
Erros comuns:
- AmazonS3Exception — Credenciais inválidas, bucket não existe
- TimeoutException — Timeout de rede
- TaskCanceledException — Operação cancelada
Custos R2¶
| Recurso | Custo |
|---|---|
| Armazenamento | $0.015/GB/mês |
| Operações Classe A (PUT, POST, LIST) | $4.50/milhão |
| Operações Classe B (GET, HEAD) | $0.36/milhão |
| Egress (via Cloudflare) | Grátis |
Dica: R2 tem egress grátis via Cloudflare CDN, ideal para mídias acessadas frequentemente.
Ambiente de Desenvolvimento¶
Para desenvolvimento local, usar MinIO como substituto do R2:
# docker-compose.dev.yaml
minio:
image: minio/minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
Configuração para MinIO:
{
"R2": {
"AccountId": "",
"AccessKeyId": "minioadmin",
"SecretAccessKey": "minioadmin",
"BucketName": "ciba-media",
"PublicUrl": "http://localhost:9000/ciba-media"
}
}
// Ajuste no ServiceURL para desenvolvimento
var config = new AmazonS3Config
{
ServiceURL = isDevelopment
? "http://localhost:9000"
: $"https://{options.AccountId}.r2.cloudflarestorage.com",
ForcePathStyle = true
};
Considerações de Segurança¶
- Credenciais: Nunca commitar credenciais R2. Usar secrets em produção.
- URLs Públicas: Mídias são públicas via URL. Se necessário controle de acesso, implementar signed URLs.
- Validação de Mídia: Validar tamanho máximo antes do upload (ex: 50MB).
- CORS: Configurar CORS no bucket se frontend acessar diretamente.
Limpeza de Mídias¶
A limpeza é gerenciada via lifecycle rules do R2/S3 por prefixo:
| Prefixo | Regra |
|---|---|
conversations/ |
Expiração automática após 90 dias |
playground/ |
Expiração automática após 30 dias |
agents/ |
Sem expiração (permanente) |
As regras devem ser configuradas diretamente no dashboard do Cloudflare R2 ou via API S3 Lifecycle Configuration.