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 - Deleção em massa por prefixo (limpeza de dados de tenant)
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);
/// Deletes all objects under a given key prefix.
/// Used for tenant data cleanup.
Task<int> DeleteByPrefixAsync(string prefix, 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. DeleteByPrefixAsync retorna a quantidade de objetos deletados.
R2StorageService — Construtor¶
O R2StorageService recebe as seguintes dependências:
public R2StorageService(
IAmazonS3 s3,
IOptions<R2Options> options,
PerformanceMonitorOptions perfOptions,
ILogger<R2StorageService> logger)
| Parâmetro | Tipo | Descrição |
|---|---|---|
s3 |
IAmazonS3 |
Cliente S3 configurado para R2 |
options |
IOptions<R2Options> |
Configurações do bucket R2 |
perfOptions |
PerformanceMonitorOptions |
Thresholds de monitoramento de performance |
logger |
ILogger<R2StorageService> |
Logger para operações de storage |
Monitoramento de Performance¶
Todas as operações de storage (upload e fetch) são monitoradas via Stopwatch e logadas com níveis diferentes conforme os thresholds configurados em PerformanceMonitorOptions.Storage.
Método LogStorageTiming¶
private void LogStorageTiming(string operation, string key, long elapsedMs)
{
var thresholds = _perfOptions.Storage;
if (elapsedMs >= thresholds.WarningMs)
_logger.LogWarning("Slow R2 {Operation} ({ElapsedMs}ms): {Key}",
operation, elapsedMs, key);
else if (elapsedMs >= thresholds.SlowMs)
_logger.LogInformation("R2 {Operation} ({ElapsedMs}ms): {Key}",
operation, elapsedMs, key);
else
_logger.LogDebug("R2 {Operation} ({ElapsedMs}ms): {Key}",
operation, elapsedMs, key);
}
Thresholds Padrão (Storage)¶
| Nível | Threshold | Log Level |
|---|---|---|
| Normal | < 1.000 ms | Debug |
| Lento | >= 1.000 ms | Information |
| Aviso | >= 5.000 ms | Warning |
Os thresholds podem ser configurados globalmente via PerformanceMonitorOptions.Storage = new ThresholdPair(SlowMs, WarningMs).
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 |
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.
Tratamento de Erros¶
O upload utiliza um parâmetro label para identificar o tipo de operação nos logs:
try
{
// ... upload logic ...
return publicUrl;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to upload {Label} to R2: {Key}", label, key);
throw;
}
O label varia conforme o método chamado:
| Método | Label |
|---|---|
SaveMediaAsync |
"Media" |
SaveAttachmentAsync |
"Attachment" |
SavePlaygroundMediaAsync |
"Playground media" |
Erros comuns:
- AmazonS3Exception — Credenciais inválidas, bucket não existe
- TimeoutException — Timeout de rede
- TaskCanceledException — Operação cancelada
Deleção por Prefixo¶
O método DeleteByPrefixAsync permite deletar todos os objetos sob um prefixo. Utilizado para limpeza de dados de tenant.
public async Task<int> DeleteByPrefixAsync(string prefix, CancellationToken ct = default)
{
// Lista objetos em batches de 1000
// Deleta cada batch via DeleteObjectsAsync
// Retorna total de objetos deletados
}
Comportamento:
- Pagina automaticamente via ContinuationToken (batches de 1000)
- Loga quantidade deletada em Information se > 0
- Retorna total de objetos removidos
Health Checks¶
Os health checks da aplicação cobrem PostgreSQL e Redis apenas. Não existe health check específico para R2 Storage.
services.AddHealthChecks()
.AddNpgSql(pgConnectionString, name: "postgresql", tags: ["ready"])
.AddRedis(redisConnectionString, name: "redis", tags: ["ready"]);
Falhas no R2 são tratadas via try/catch nos métodos de upload/fetch e logadas como erro.
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.
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 (16 MB via
AppConfig.MaxAttachmentFileSizeBytes). - 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.
Para deleção programática por prefixo, usar IFileStorageService.DeleteByPrefixAsync(prefix, ct).
Arquivos Relacionados¶
| Arquivo | Descrição |
|---|---|
src/Ciba.Infrastructure/Services/Storage/IFileStorageService.cs |
Interface de abstração do storage |
src/Ciba.Infrastructure/Services/Storage/R2StorageService.cs |
Implementação R2 via AWS S3 SDK |
src/Ciba.Infrastructure/Services/Storage/StoragePath.cs |
Builder fluente para paths de storage |
src/Ciba.Infrastructure/Configuration/R2Options.cs |
Options pattern para configuração R2 |
src/Ciba.Infrastructure/Configuration/PerformanceMonitorOptions.cs |
Thresholds de monitoramento de performance |
src/Ciba.Infrastructure/Configuration/AppConfig.cs |
Constantes da aplicação (limites de arquivo) |
src/Ciba.Api/Extensions/ServiceCollectionExtensions.cs |
Registro do IAmazonS3 e R2StorageService |