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


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:

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

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.


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

  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 (ex: 50MB).
  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.