Skip to main content

Media Storage

Flo stores uploads through an abstraction (IStorageProvider) with two implementations, selected by STORAGE_PROVIDER:

ProviderSTORAGE_PROVIDERBacking storePublic URLs
LocalFileSystemStorageProviderlocal (default)wwwroot/uploads on the container volumeServed by the API (ApiBaseUrl)
R2StorageProviderr2Cloudflare 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:

  1. Presign — POST /api/v1/storage/presign (or POST /api/public/v1/{entityRoute}/{id}/media/presign) validates the request and returns a presigned PUT URL plus a _pending key:

    {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.

  2. 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.

  3. Confirm — POST /api/v1/storage/confirm checks:

    • the pending key belongs to the tenant and lives under /_pending/;
    • the same user who presigned is confirming;
    • the stored Content-Type matches 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.

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

LimitValue
Max file size100 MB (104,857,600 bytes)
Image max pixels100 MP (animated GIF/WebP: 50 MP total, frontend best-effort)
Image MIME typesimage/jpeg, image/png, image/gif, image/webp
Video containervideo/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/mp4 everywhere; document types (application/pdf, DOCX/XLSX/PPTX, ZIP, TXT, CSV) only in the attachments folder.
  • 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 _pending keys 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 capabilityImageVideo
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 originalDownload → 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_resizing and enable_media_optimization are on, CF wins for OptimizedUrl.
  • Requires a transform-capable CDN hostname: a custom domain inside a Cloudflare zone with Transformations enabled. A bare *.r2.dev managed 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 through format=json and video dimensions through ffprobe, then persisted in MediaDimension so 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:

MethodEndpointRolePurpose
POST/runSuperAdminTrigger a scan ({ entityTypes?: string[] }, omitted = all enabled)
POST/cancelSuperAdminCancel the running scan
GET/statusAdmin/SuperAdminLatest run, per-type breakdown, live feed, available types
GET/runs?take=NAdmin/SuperAdminRun history
GET/flagged?entityType=XProFlagged 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​

FilePurpose
Flo.BE/Services/Storage/IStorageProvider.csStorage abstraction
Flo.BE/Services/Storage/R2StorageProvider.cs / LocalFileSystemStorageProvider.csProvider implementations
Flo.BE/Services/Storage/StorageUploadService.csPresign → confirm flow, validation, rename
Flo.BE/Services/Storage/StorageMediaUrlPolicy.csMedia 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.csHTTP surface
shared/cloudflare-media-limits.jsonUpload limits shared with the frontend

Related: FFmpeg, Feature Flags, Environment Variables.