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.
StorageClient Trait
Section titled “StorageClient Trait”All file operations go through StorageClient, which dispatches to the active backend.
Core operations:
put(key, bytes, content_type)— upload a fileget(key)— download a file into memoryexists(key)— check if a file existsread_access(key, expires_in)— get the best read target for browser accesspresign_get(key, expires_in)— generate a presigned URL (R2 only)
The client is a singleton initialized at startup and accessed via storage_client().
Backends
Section titled “Backends”Local Disk
Section titled “Local Disk”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.
Cloudflare R2
Section titled “Cloudflare R2”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).
Configuration
Section titled “Configuration”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 errorEnvironment variables (used by seed script):
R2_ACCOUNT_IDR2_ACCESS_KEY_IDR2_SECRET_ACCESS_KEYR2_BUCKET
Database migration: m20260306_000003_core_conf_add_r2_fields.rs adds four R2 columns to core_conf.
Org-Scoped Keys
Section titled “Org-Scoped Keys”Files are namespaced by organization to enforce isolation. Keys typically follow this pattern:
recordings/{org_id}/{call_sid}.mp3uploads/{org_id}/{upload_id}.pdfThis prevents one organization from accessing another’s files and enables future per-org storage quotas.
Presigned URLs
Section titled “Presigned URLs”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 Strategy
Section titled “Read Access Strategy”read_access(key, expires_in) returns the optimal way for a client to read a file:
| Backend | Result |
|---|---|
| Local | DirectBytes(Vec<u8>) — full file in memory |
| R2 | PresignedUrl(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.
Migration Path
Section titled “Migration Path”To switch from local to R2:
- Set R2 config in
core_conf(via admin UI or direct DB update) - Restart the app
- Upload new files to R2
- 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.
Module Structure
Section titled “Module Structure”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