Skip to main content

File storage and attachments

Files uploaded with POST /api/v1/converger/conversations/:id/upload are validated, written to a pluggable storage backend and recorded in the attachments table. The activity's contentUrl always points at the authenticated download endpoint:

GET /api/v1/converger/attachments/:id
Authorization: Bearer <converger token>
  • The endpoint is tenant scoped. An attachment that belongs to another tenant returns 404. So does an attachment from another conversation when the token is bound to a conversation.
  • Local storage: the file is streamed with its sniffed Content-Type, X-Content-Type-Options: nosniff, a sandboxing CSP and a Content-Disposition header (inline for images, audio, video and PDF, attachment for everything else).
  • Cloud storage: the endpoint answers 302 to a short-lived signed URL (signed_url_ttl, 300 s by default). If a CDN is configured, the redirect points at the CDN URL instead.

Nothing is served from priv/static any more.

Validation​

CheckBehaviour
Sizemax_file_size bytes (default 10 MB). Larger uploads get 413. The multipart parser limit follows this value at runtime (plus 1 MB for form fields).
TypeDetected from magic bytes (Converger.Uploads.MimeSniffer). The client's declared type is ignored.
Allowlisttenants.allowed_upload_types (text array) when set, otherwise allowed_content_types. Disallowed types get 415.

Types the sniffer recognises: image/png, image/jpeg, image/gif, image/webp, image/bmp, application/pdf, video/mp4, video/quicktime, video/3gpp, video/webm, audio/mpeg, audio/aac, audio/ogg, audio/mp4, audio/wav, application/zip, and docx/xlsx/pptx. Any other valid UTF-8 without NUL bytes is text/plain. Everything else is application/octet-stream. SVG and HTML are therefore stored and served as text/plain, so they can't run scripts.

Default allowlist: png, jpeg, gif, webp, pdf, mp4, webm, quicktime, mpeg, ogg, mp4 audio, aac, wav, text/plain, docx, xlsx, pptx. application/zip and application/octet-stream are not allowed by default.

Each attachment row stores tenant_id, conversation_id, activity_id (nullable, set once the activity is created), storage_key (<tenant_id>/<attachment_id>), content_type, size, sha256 and filename.

Configuration​

config :converger, Converger.Uploads,
storage: Converger.Uploads.LocalStorage, # backend module
storage_opts: [dir: "priv/uploads"], # passed to the backend
max_file_size: 10 * 1024 * 1024,
allowed_content_types: nil, # nil = built-in default list
signed_url_ttl: 300, # seconds
cdn: nil # see "CDN" below

In releases, config/runtime.exs builds this from environment variables when UPLOAD_STORAGE is set. It never does this in the test environment.

VariableMeaning
UPLOAD_STORAGElocal, s3, minio, r2, gcs or azure
UPLOAD_MAX_BYTESmax upload size in bytes (default 10485760)
UPLOAD_ALLOWED_TYPEScomma separated MIME allowlist (default: built-in list)
UPLOAD_SIGNED_URL_TTLsigned URL lifetime in seconds (default 300)

Local disk: Converger.Uploads.LocalStorage​

Intended for development and single-node deployments. Files are written below dir, which defaults to priv/uploads (git-ignored, not served statically). On a release, point it at a persistent volume, because files inside the release directory are lost on redeploy and are not shared between nodes.

OptionEnvDefault
dirUPLOAD_DIRpriv/uploads

Amazon S3 / MinIO / Cloudflare R2: Converger.Uploads.S3Storage​

Uses Req and a hand-written AWS Signature V4 implementation (Converger.Uploads.Signers.SigV4). Supported operations: PUT, GET, DELETE, presigned GET (with response-content-type / response-content-disposition overrides) and presigned PUT.

OptionEnvNotes
bucketS3_BUCKETrequired
access_key_idS3_ACCESS_KEY_IDrequired
secret_access_keyS3_SECRET_ACCESS_KEYrequired
session_tokenS3_SESSION_TOKENoptional (STS)
regionS3_REGIONdefault us-east-1; use auto for R2
endpointS3_ENDPOINTdefault https://s3.<region>.amazonaws.com
path_styleS3_PATH_STYLEfalse for s3, true for minio/r2

Examples:

  • MinIO: UPLOAD_STORAGE=minio S3_ENDPOINT=http://minio:9000 S3_BUCKET=converger
  • R2: UPLOAD_STORAGE=r2 S3_ENDPOINT=https://<account_id>.r2.cloudflarestorage.com S3_REGION=auto

The bucket must already exist and should be private.

Google Cloud Storage: Converger.Uploads.GCSStorage​

Uses the GCS XML API with HMAC keys. Requests are signed with GOOG4-HMAC-SHA256, the SigV4-compatible scheme, with the same signer as S3. Create a key with gcloud storage hmac create SERVICE_ACCOUNT_EMAIL. Signing with a service-account RSA key (JSON key file) is not implemented.

OptionEnvNotes
bucketGCS_BUCKETrequired
access_key_idGCS_HMAC_ACCESS_IDrequired
secret_access_keyGCS_HMAC_SECRETrequired
endpointGCS_ENDPOINTdefault https://storage.googleapis.com
regionnonecredential scope location, default auto

Azure Blob Storage: Converger.Uploads.AzureBlobStorage​

PUT, GET and DELETE use Shared Key authorization. Signed GET URLs are Service SAS tokens (sp=r, sv=2020-12-06, with rsct/rscd overrides). Presigned uploads are SAS tokens with sp=cw, and the client must send x-ms-blob-type: BlockBlob.

OptionEnvNotes
accountAZURE_STORAGE_ACCOUNTrequired
account_keyAZURE_STORAGE_KEYbase64 account key, required
containerAZURE_STORAGE_CONTAINERrequired, private
endpointAZURE_STORAGE_ENDPOINTdefault https://<account>.blob.core.windows.net; Azurite: http://127.0.0.1:10000/devstoreaccount1

CDN​

A CDN layer (Converger.Uploads.CDN) can sit in front of any backend. When it is configured, downloads redirect to the CDN URL <base_url>/<path_prefix>/<storage_key> instead of the storage URL.

cdn: [type: :cloudfront | :google_cdn | :plain, base_url: "https://...", path_prefix: "", ...]
EnvMeaning
CDN_TYPEcloudfront, google_cdn or plain
CDN_BASE_URLCDN origin, e.g. https://d111111abcdef8.cloudfront.net
CDN_PATH_PREFIXoptional prefix prepended to the key

Supported providers​

ProviderTypeSigned URLsNotes
Amazon CloudFront:cloudfrontyes, canned policy (RSA-SHA1)key_pair_id / CLOUDFRONT_KEY_PAIR_ID, private_key / CLOUDFRONT_PRIVATE_KEY (PEM) or CLOUDFRONT_PRIVATE_KEY_FILE. Works with both key groups (public key ID) and legacy key pairs. Use OAC/OAI so the bucket stays private.
Google Cloud CDN:google_cdnyes (HMAC-SHA1)key_name / GOOGLE_CDN_KEY_NAME, key / GOOGLE_CDN_KEY (base64url, from gcloud compute backend-buckets add-signed-url-key).
Cloudflare (R2 public bucket / custom domain, or a proxied origin):plainnoUnsigned URLs. Only use this with content you accept as public-by-URL. Keys are unguessable UUIDs, but anyone with the link can download.
Azure CDN / Azure Front Door:plainvia origin SASSet sign_origin: true (CDN_SIGN_ORIGIN=true) to append the backend's SAS query string to the CDN URL. The CDN must forward query strings to the origin and cache per query string.
Any other CDN:plainnoUnsigned base_url/key URLs.

Not supported: CloudFront custom policies (IP or start-time conditions) and signed cookies, Google Cloud CDN signed cookies and URL-prefix signatures, Cloudflare signed URL tokens (Workers / WAF HMAC), and Azure Front Door token authentication.

Large files and presigned uploads​

All cloud backends implement presigned_put_url/3, and Converger.Uploads.presigned_put_url/2 exposes it. Bytes uploaded directly to storage skip server-side MIME sniffing, so a public HTTP flow for direct uploads (reserve, upload, then finalize and verify) is not part of the API yet.

Channel media​

Files do not only come from client uploads. Inbound media of WhatsApp channels (images, voice notes, documents) is downloaded and stored through the same create_attachment/3 checks (size limit, sniffed type, tenant allowlist), with the conversation set, and linked to the inbound activity; a file the checks reject stays a provider stub with channelData.downloadError (WhatsApp: inbound media download). Allow audio/ogg (WhatsApp voice notes) in a tenant's allowed_upload_types if you restrict it.

Outbound, an activity attachment that points to an upload is read from storage by the WhatsApp Cloud API adapter and uploaded to Meta. Infobip fetches media by URL, so it gets the signed storage or CDN URL that download/1 returns; with LocalStorage there is none and such deliveries fail permanently, so use a cloud backend or a CDN for Infobip media.

Retention archive​

The same storage backends hold the archive of expired activities and deliveries (Data retention, issue #30). Objects are gzip-compressed JSON Lines, at most 50,000 rows each:

archive/<tenant_id>/<YYYY-MM>/activities-00001.jsonl.gz
archive/<tenant_id>/<YYYY-MM>/deliveries-00001.jsonl.gz

By default they go to the attachment bucket under the archive/ prefix. To use a separate bucket with the same credentials set ARCHIVE_BUCKET (S3, MinIO, R2, GCS), ARCHIVE_CONTAINER (Azure) or ARCHIVE_DIR (local disk); ARCHIVE_PREFIX changes the prefix. In config/*.exs:

config :converger, Converger.Archive,
storage: Converger.Uploads.S3Storage, # nil: the attachment storage
storage_opts: [bucket: "converger-archive", region: "eu-central-1", ...],
prefix: "archive",
part_rows: 50_000

The archive needs PutObject and GetObject (every part is downloaded again and its SHA-256 compared before anything is dropped); Converger never deletes archive objects. Turn on versioning or object lock on that bucket and keep delete rights away from the application's credentials where your provider allows it.

Testing​

  • Unit tests cover each signer against known vectors: the published AWS SigV4 S3 examples, the documented Azure Shared Key and SAS string-to-sign layouts, CloudFront signature verification with a generated RSA key, and the Google CDN HMAC. Backends are tested with Req.Test stubs.

  • Live tests against MinIO and Azurite are tagged :minio and :azurite and are excluded by default:

    docker run -d -p 9000:9000 -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin bitnamilegacy/minio
    docker run -d -p 10000:10000 mcr.microsoft.com/azure-storage/azurite azurite-blob --blobHost 0.0.0.0
    mix test --only minio --only azurite

    MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY and AZURITE_ENDPOINT override the defaults. CI runs these tests in the storage-integration job (.github/workflows/ci.yml).