Media Storage
Flo stores uploads through an abstraction (IStorageProvider) with two implementations, selected by STORAGE_PROVIDER:
| Provider | STORAGE_PROVIDER | Backing store | Public URLs |
|---|---|---|---|
LocalFileSystemStorageProvider | local (default) | wwwroot/uploads on the container volume | Served by the API (ApiBaseUrl) |
R2StorageProvider | r2 | Cloudflare R2 (S3-compatible) | R2_CDN_URL/<key> |
When the failover lease is armed (FAILOVER_ENABLED=true), the provider is wrapped by PrimaryLeaseStorageProvider, which denies writes on a standby node.
Relevant environment variables: STORAGE_PROVIDER, R2_ENDPOINT, R2_ACCESS_KEY, R2_SECRET_KEY, R2_BUCKET, R2_CDN_URL, R2_TENANT_PREFIX — see Environment Variables.
Upload Flow (Presign → PUT → Confirm)
Authenticated clients (and public API tokens with media.write) upload through a two-phase flow implemented by StorageUploadService:
-
Presign —
POST /api/v1/storage/presign(orPOST /api/public/v1/{entityRoute}/{id}/media/presign) validates the request and returns a presigned PUT URL plus a_pendingkey:{tenant}/_pending/{folder}/{guid}.{ext}Presigns are bound to the requesting user and declared content type. Expiry: 5 minutes for images, 15 minutes for video/documents.
-
Client PUT — the browser uploads directly to R2 (no bytes through the API). The local provider instead accepts a classic multipart upload via
POST /api/v1/storage/upload. -
Confirm —
POST /api/v1/storage/confirmchecks:- the pending key belongs to the tenant and lives under
/_pending/; - the same user who presigned is confirming;
- the stored
Content-Typematches the presigned one; - the size is below Cloudflare's limit (
< 100 MB); - the first 64 bytes match the expected magic bytes for the type.
On success the object is moved from
_pending/to its final key, ownership is recorded, and an optimization job is queued for images/videos. - the pending key belongs to the tenant and lives under
Renames (POST /api/v1/storage/rename) are restricted to the uploader or staff (Admin/Pro); file names are sanitized and keys stay inside the tenant prefix.
Validation Rules
Validation is driven by shared/cloudflare-media-limits.json, the single source of truth shared with the frontend (CloudflareMediaLimits / media-validation.service.ts):
| Limit | Value |
|---|---|
| Max file size | 100 MB (104,857,600 bytes) |
| Image max pixels | 100 MP (animated GIF/WebP: 50 MP total, frontend best-effort) |
| Image MIME types | image/jpeg, image/png, image/gif, image/webp |
| Video container | video/mp4, max 600 s, H.264 video, AAC/MP3 audio |
Uploads are additionally constrained by:
- Allowed folders:
activities,galleries,users,immobili/immobiles,catalogitems/catalog-items,attachments,newsletter,activity-information,articles,authors. - Content types: images +
video/mp4everywhere; document types (application/pdf, DOCX/XLSX/PPTX, ZIP, TXT, CSV) only in theattachmentsfolder. - Extension ↔ content-type match: the file extension must be one of the exact extensions allowed for the declared type.
- Media URL policy: generic CRUD payloads may only reference URLs the tenant's storage provider produced (
StorageMediaUrlPolicy); external URLs and_pendingkeys are rejected. - Rate limits: presign 120/min per user, media upload 120/min per token/IP, public integration writes 300/min per token, anonymous public browser flows 60/min per IP.
Server-Side Optimization Pipeline
Behind the enable_media_optimization flag. MediaOptimizationBackgroundService polls every 2 minutes (and is woken by upload triggers), processes one job at a time (OOM prevention on shared VPS), retries up to 3 times, and cleans leftover media-temp/ directories at startup. The branch depends on provider capabilities:
| Provider capability | Image | Video |
|---|---|---|
Local (SupportsImageOptimization=true, SupportsServerSideVideoOptimization=false) | Optimized in place on disk (atomic replace only if smaller) | Optimized in place |
R2 (SupportsImageOptimization=false, SupportsServerSideVideoOptimization=true) | Download → variants → upload → delete original | Download → variants → upload → delete original |
Image variants (R2): widths 320, 640, 1024, 1920 WebP plus an original-size WebP; variants wider than the source are skipped; sources over 8000 px are downscaled first. Video variants (R2): 480p, 720p, 1080p H.264 MP4, skipping heights above the source. Variants are stored next to the original with a _320w.webp / _480p.mp4 suffix and registered as MediaVariant rows keyed by the source storage key. The original blob is deleted only after at least one variant exists.
Reads resolve stored variants to the largest variant URL (or a srcset), with a read-through MediaDimension cache for intrinsic dimensions.
Cloudflare Edge Resizing
Behind the enable_cf_image_resizing flag. When on and the storage is CDN-backed, the backend rewrites media URLs to Cloudflare transformations instead of serving stored variants:
https://cdn.example.com/cdn-cgi/image/format=auto,quality=80,width=1920/<path> # images
https://cdn.example.com/cdn-cgi/media/mode=video,width=720/<path> # videos
- Priority: when both
enable_cf_image_resizingandenable_media_optimizationare on, CF wins forOptimizedUrl. - Requires a transform-capable CDN hostname: a custom domain inside a Cloudflare zone with Transformations enabled. A bare
*.r2.devmanaged domain 404s on/cdn-cgi/, so the backend treats it as transform-incapable and returns the original URL. - Card thumbnails use a narrower
width; image dimensions are probed throughformat=jsonand video dimensions through ffprobe, then persisted inMediaDimensionso the probe runs once per blob. - URLs that already contain
/cdn-cgi/are left untouched (idempotent).
Media Compatibility Scan
SuperAdmin console (Settings > Media compatibility) that finds media violating Cloudflare's limits, so incompatible assets can be fixed manually — there is no automated remediation. Endpoints live under /api/v1/media-scan:
| Method | Endpoint | Role | Purpose |
|---|---|---|---|
| POST | /run | SuperAdmin | Trigger a scan ({ entityTypes?: string[] }, omitted = all enabled) |
| POST | /cancel | SuperAdmin | Cancel the running scan |
| GET | /status | Admin/SuperAdmin | Latest run, per-type breakdown, live feed, available types |
| GET | /runs?take=N | Admin/SuperAdmin | Run history |
| GET | /flagged?entityType=X | Pro | Flagged media, all types or one |
Six IScannableMediaSource adapters enumerate media-bearing entities: CatalogItem (gated by enable_catalogo), Immobile (gated by enable_immobili), Activity, User, Gallery and image/video FileAttachments. Each item is inspected tier-by-tier (HEAD metadata, then a full ffprobe stream) and the result is upserted as a MediaScanResult with a NeedsFixing flag and a JSON list of issues. One failing source never aborts the run.
Code Reference
| File | Purpose |
|---|---|
Flo.BE/Services/Storage/IStorageProvider.cs | Storage abstraction |
Flo.BE/Services/Storage/R2StorageProvider.cs / LocalFileSystemStorageProvider.cs | Provider implementations |
Flo.BE/Services/Storage/StorageUploadService.cs | Presign → confirm flow, validation, rename |
Flo.BE/Services/Storage/StorageMediaUrlPolicy.cs | Media URL validation for CRUD payloads |
Flo.BE/Services/MediaOptimization/ | Background pipeline, variants, CF URL policy, dimension probes |
Flo.BE/Services/Media/ | Compatibility scan, inspection, Cloudflare limits |
Flo.BE/Controllers/StorageController.cs / MediaScanController.cs | HTTP surface |
shared/cloudflare-media-limits.json | Upload limits shared with the frontend |
Related: FFmpeg, Feature Flags, Environment Variables.