Skip to content

Storage

Loquent uses a pluggable storage layer that abstracts local disk and Cloudflare R2 backends behind a single StorageClient interface. You configure the backend once at startup; the rest of the app calls the same methods regardless of where files live.

All file operations go through StorageClient, which dispatches to the active backend.

Core operations:

  • put(key, bytes, content_type) — upload a file
  • get(key) — download a file into memory
  • exists(key) — check if a file exists
  • read_access(key, expires_in) — get the best read target for browser access
  • presign_get(key, expires_in) — generate a presigned URL (R2 only)

The client is a singleton initialized at startup and accessed via storage_client().

Files are stored in a directory tree rooted at STORAGE_ROOT (default: ./storage). Keys map directly to file paths.

StorageClient::local("./storage")

Key validation:

  • Rejects absolute paths
  • Rejects parent directory components (../)
  • Creates nested directories as needed

Read access: returns DirectBytes(Vec<u8>) — the full file contents.

Files are stored in an S3-compatible R2 bucket. The client uses AWS SDK with R2-specific endpoint URL.

StorageClient::r2(R2StorageConfig {
account_id: "...",
access_key_id: "...",
secret_access_key: "...",
bucket: "loquent-recordings",
}).await?

Read access: returns PresignedUrl(String) — a time-limited signed URL for direct browser download.

Configuration source: R2 credentials live in core_conf table, loaded at startup. All four fields must be set or all blank (partial config is rejected).

The storage backend is selected at startup based on R2 config completeness.

Decision flow:

Load core_conf from database
→ If R2 fields are all blank: use LocalStorageClient
→ If R2 fields are all set: use R2StorageClient
→ If R2 fields are partial: return error

Environment variables (used by seed script):

  • R2_ACCOUNT_ID
  • R2_ACCESS_KEY_ID
  • R2_SECRET_ACCESS_KEY
  • R2_BUCKET

Database migration: m20260306_000003_core_conf_add_r2_fields.rs adds four R2 columns to core_conf.

Files are namespaced by organization to enforce isolation. Keys typically follow this pattern:

recordings/{org_id}/{call_sid}.mp3
uploads/{org_id}/{upload_id}.pdf

This prevents one organization from accessing another’s files and enables future per-org storage quotas.

R2 supports presigned URLs for client-side downloads without proxying bytes through the app server. Local storage does not support presigned URLs (returns error).

Usage:

let url = storage_client().presign_get("recordings/123/call.mp3", Duration::from_secs(3600)).await?;
// Returns: https://{account}.r2.cloudflarestorage.com/{bucket}/recordings/123/call.mp3?X-Amz-...

The URL expires after the specified duration. Browsers can fetch it directly without auth headers.

read_access(key, expires_in) returns the optimal way for a client to read a file:

BackendResult
LocalDirectBytes(Vec<u8>) — full file in memory
R2PresignedUrl(String) — signed URL for browser fetch

This allows the same API endpoint to serve files differently based on backend. For example, GET /api/call/recording/{id} uses read_access() and either returns bytes or redirects to a presigned URL.

To switch from local to R2:

  1. Set R2 config in core_conf (via admin UI or direct DB update)
  2. Restart the app
  3. Upload new files to R2
  4. Optionally migrate existing local files to R2 using a maintenance script

Files uploaded before the switch remain in local storage unless manually migrated. The app does not automatically sync backends.

src/bases/storage/
├── mod.rs # StorageClient + backend enum
├── local.rs # LocalStorageClient
├── r2.rs # R2StorageClient
├── r2_core_conf.rs # Load R2 config from DB
└── storage_client_static.rs # Singleton accessor
migration/src/
├── m20260306_000003_core_conf_add_r2_fields.rs
└── m20260306_000004_core_conf_seed_r2_fields.rs
  • Twilio — uses storage for call recordings
  • Store — general-purpose file uploads