Storage and CDN
DropAFinder uses Cloudflare R2 (S3-compatible) as its primary object store. Two distinct categories of data live there: location images uploaded by workspace owners, and finder payloads written by the backend on every publish. Cloudflare’s CDN sits in front of R2 and serves both categories to visitors worldwide.
For the publish trigger that causes payload writes, see Data flow — builder. For how the visitor-side widget reads payloads from the CDN, see Data flow — end user.
Flysystem disk configuration
The backend uses Laravel’s Flysystem abstraction with the s3 driver. Two disks are configured in config/filesystems.php:
| Disk name | Purpose | Driver |
|---|---|---|
s3 | Location images (media) | s3 → AWS S3 or R2 |
r2 | Finder payloads (CDN-served JSON) | s3 → Cloudflare R2 |
Both disks use the AWS S3 protocol. The R2 disk points its endpoint at the R2 account-specific URL (https://{ACCOUNT_ID}.r2.cloudflarestorage.com) and sets use_path_style_endpoint = true, which is required for R2 compatibility with the S3 SDK.
Relevant env vars (from .env.example):
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_ACCOUNT_ID=
R2_BUCKET=
CDN_BASE_URL=https://cdn.locationfinders.comFILESYSTEM_DISK defaults to local in development. Setting it to s3 or r2 in production routes all Storage::disk() calls to the appropriate remote store.
Source: dropafinder-app-backend/config/filesystems.php
Location image upload pipeline
Workspace owners upload images on a per-location basis. The upload hits the backend, which streams the file to the configured S3/R2 disk and returns a public CDN URL for storage in the location record.
Route: POST /locations/{id}/image
Approximate controller flow (LocationController):
- Validate the incoming multipart request (file type, max size).
- Store the file:
Storage::disk('s3')->put("locations/{$id}/image", $file, 'public'). - Generate the CDN URL by prepending
CDN_BASE_URLto the stored path. - Persist the URL on the
Locationmodel and return the updated location record.
The returned URL (https://cdn.locationfinders.com/locations/{id}/image) is what gets stored in the database and embedded in the finder payload. The widget renders this URL directly in <img> tags inside location cards — there is no image proxy in the widget.
🔴 [NEEDS CLARIFICATION: Confirm exact storage path template and whether images use a content-addressed filename or a fixed
/imagepath that overwrites on re-upload.]
Source: dropafinder-app-backend/app/Http/Controllers/LocationController.php
Finder payload caching — the two-file pattern
Every finder publish writes exactly two files to the r2 disk. This pattern lets the CDN cache payloads indefinitely while still picking up new versions within 60 seconds.
File 1 — Hash-versioned payload (immutable)
finders/{finderKey}/v/{sha1hash}.json
Cache-Control: max-age=31536000, immutableThis is the full finder payload: design settings, location list, custom field definitions, and refinement configuration. It is written once per publish and never overwritten or deleted. Because the URL includes the content hash, a different payload always has a different URL — old URLs remain valid (and cached at the CDN edge) indefinitely.
File 2 — Pointer (short TTL)
finders/{finderKey}/config.json
Cache-Control: max-age=60This is a small JSON object that tells the widget which hash-versioned payload to load:
{ "hash": "a1b2c3d4ef12", "variant": "default", "published_at": "2026-04-27T12:00:00Z" }The pointer is overwritten on every publish. Its 60-second TTL is the upper bound on how long it takes for a new publish to reach visitors with a cold pointer cache.
Content hash derivation
The hash is computed in ExternalController::syncFinderDetails:
$hash = substr(sha1(json_encode($hashPayload)), 0, 12);Only the first 12 characters of the SHA-1 hex digest are used. This is short enough to keep URLs tidy while long enough to avoid accidental collisions across the volume of publishes expected.
Source: dropafinder-app-backend/app/Http/Controllers/ExternalController.php:164-212
syncFinderDetails — payload build and upload sequence
ExternalController::syncFinderDetails is the backend method that executes on every Finder publish. It is triggered by:
POST /api/ext/finders/details/{token}/syncThe frontend calls this endpoint from the Dashboard publish mutation after saving the draft. Full publish flow detail is in Data flow — builder.
The method’s internal sequence:
- Resolve finder — look up the Finder by its public
token; confirm it belongs to an authorized workspace. - Build payload —
buildPayload($finder)assembles the full JSON from the finder’s settings, attached maps, location records, custom field definitions, and refinement config. - Hash the payload —
substr(sha1(json_encode($hashPayload)), 0, 12). - Store in Laravel Cache —
Cache::put('finder_details_' . $key, $payload). This serves as the origin fallback store that the widget hits if the CDN fetch fails. - Upload versioned payload —
Storage::disk('r2')->put("finders/{$key}/v/{$hash}.json", $payload)withCache-Control: max-age=31536000, immutable. - Upload pointer —
Storage::disk('r2')->put("finders/{$key}/config.json", $pointer)withCache-Control: max-age=60. - Purge Cloudflare cache — sends a purge request for the pointer URL so stale pointer caches at the edge are cleared immediately. The versioned payload URL is not purged (it is immutable by design).
- Return —
{ hash, published_at, cdn_synced: true/false }.
cdn_synced is false if either the R2 upload or the Cloudflare purge fails. The dashboard surfaces this to the owner as a warning. The origin fallback (step 4) remains valid regardless of CDN sync status.
Source: dropafinder-app-backend/app/Http/Controllers/ExternalController.php:164-212
Widget fetch sequence (pointer → payload)
The widget reads from the CDN in two hops:
- Fetch pointer —
GET https://cdn.locationfinders.com/finders/{finderKey}/config.json(60s TTL). Returns{ hash, variant, published_at }. - Resolve payload — check
localStoragekeylf-cdn-v2:{finderKey}for a hash match; on miss, fetchhttps://cdn.locationfinders.com/finders/{finderKey}/v/{hash}.json(immutable, 1-year TTL) and store the result in localStorage; fall back to the origin API if the CDN fetch fails.
Full sequence with code-level detail is in Data flow — end user.
Source: dropafinder-app-external/ext/2/source/src/lib/api.js:39-95
Cloudflare cache invalidation
Cloudflare is the CDN layer in front of R2. Cache invalidation is handled selectively:
| File | Invalidated on publish? | Reason |
|---|---|---|
finders/{key}/v/{hash}.json | No | Immutable — a new publish creates a new URL; the old one is orphaned, not deleted |
finders/{key}/config.json | Yes | Pointer must propagate immediately; 60s TTL alone would add up to 60s delay |
ext/v2/snippet.js (widget build) | Yes | Widget deploy script (deploy-r2.mjs) purges all affected URLs after upload |
The backend sends the Cloudflare purge for the pointer file via the Cloudflare Cache Purge API using credentials from env (CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_TOKEN). This reduces pointer propagation time from up to 60 seconds to near-instant.
🔴 [NEEDS CLARIFICATION: Confirm the exact env var names for Cloudflare purge credentials in
.env.example.]
Source: dropafinder-app-backend/app/Http/Controllers/ExternalController.php, dropafinder-app-external/ext/2/source/scripts/deploy-r2.mjs
Widget snippet deploy pipeline
The widget’s JavaScript artifacts (built by the external repo) are uploaded to R2 separately from finder payloads. This happens on each widget release, not on each finder publish.
Command: npm run deploy:02 (in dropafinder-app-external)
deploy-r2.mjs does the following:
- Reads the build output from
ext/2/dist/. - Uploads each artifact to the R2 bucket under
ext/v2/path prefix. - Sets appropriate
Content-TypeandCache-Controlheaders per file type. - Sends a Cloudflare cache purge for the affected URLs so visitors pick up the new bundle immediately.
The snippet URL embedded in customer sites (https://cdn.locationfinders.com/ext/v2/snippet.js) is stable — customers never need to update their embed code on widget deploys.
Source: dropafinder-app-external/ext/2/source/scripts/deploy-r2.mjs
Local development behavior
In local development (FILESYSTEM_DISK=local), Storage::disk() writes to storage/app/ inside the Laravel project. No R2 or CDN is involved.
The widget also has a local fallback path: app/api/ext/v2/snippet/route.ts in the Next.js repo serves the widget JS directly from the sibling dropafinder-app-external repo path, bypassing CDN. This means you can develop the widget and dashboard together without needing R2 credentials.
The origin API fallback (GET /api/ext/finders/details/{key}) is always available and reads from the Laravel Cache store (typically file-backed in local dev), so the widget renders correctly even without an R2-backed CDN.
⚠️ Warning: If you test the full publish flow locally and
FILESYSTEM_DISKis not set tor2, the payload will be written to local disk only. The CDN will not update. VerifyFILESYSTEM_DISK=r2and R2 credentials are set before testing publish end-to-end.
Source: dropafinder-app-nextjs/src/app/api/ext/v2/snippet/route.ts
Cache TTL summary
| Asset | CDN TTL | localStorage | Notes |
|---|---|---|---|
config.json pointer | 60 s | — | Purged by backend on publish |
v/{hash}.json payload | 1 year (max-age=31536000, immutable) | Until hash changes | New publish = new URL; old URL orphaned |
ext/v2/snippet.js | Long (CDN-managed) | — | Purged by deploy-r2.mjs on widget release |
| Location images | CDN-managed | — | No TTL override; CDN defaults apply |
| Origin fallback | None (real-time) | — | Laravel Cache store; used in dev + CDN miss |
Related documents
- Architecture overview — system map with storage placement
- Data flow — builder — full publish sequence including
syncFinderDetails - Data flow — end user — full widget fetch sequence including pointer/payload pattern
- External widget — init flow — widget bootstrap and API call sequencing
- Backend — storage — per-codebase storage reference