Pular para conteúdo

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:

playground/tenants/a1b2c3d4-.../agents/f1e2d3c4-.../attachments/20240120_150000_abc12345_audio.ogg

Lifecycle: 30 dias (configurado via lifecycle rule no prefixo playground/).

Anexos de Agentes (Permanentes)

agents/
└── tenants/
    └── {tenantId}/
        └── {agentId}/
            └── attachments/
                └── {fileName}

Exemplo:

agents/tenants/a1b2c3d4-.../f1e2d3c4-.../attachments/cardapio_pizzas.pdf

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:

var contentType = mimeType.Contains(';')
    ? mimeType.Split(';')[0].Trim()
    : mimeType;

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

  1. Credenciais: Nunca commitar credenciais R2. Usar secrets em produção.
  2. URLs Públicas: Mídias são públicas via URL. Se necessário controle de acesso, implementar signed URLs.
  3. Validação de Mídia: Validar tamanho máximo antes do upload (16 MB via AppConfig.MaxAttachmentFileSizeBytes).
  4. 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