These docs are a work in progress and may not be fully up to date. Some pages may contain internal notes for our team.
Skip to Content

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 namePurposeDriver
s3Location images (media)s3 → AWS S3 or R2
r2Finder 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.com

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

  1. Validate the incoming multipart request (file type, max size).
  2. Store the file: Storage::disk('s3')->put("locations/{$id}/image", $file, 'public').
  3. Generate the CDN URL by prepending CDN_BASE_URL to the stored path.
  4. Persist the URL on the Location model 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 /image path 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, immutable

This 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=60

This 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}/sync

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

  1. Resolve finder — look up the Finder by its public token; confirm it belongs to an authorized workspace.
  2. Build payloadbuildPayload($finder) assembles the full JSON from the finder’s settings, attached maps, location records, custom field definitions, and refinement config.
  3. Hash the payloadsubstr(sha1(json_encode($hashPayload)), 0, 12).
  4. Store in Laravel CacheCache::put('finder_details_' . $key, $payload). This serves as the origin fallback store that the widget hits if the CDN fetch fails.
  5. Upload versioned payloadStorage::disk('r2')->put("finders/{$key}/v/{$hash}.json", $payload) with Cache-Control: max-age=31536000, immutable.
  6. Upload pointerStorage::disk('r2')->put("finders/{$key}/config.json", $pointer) with Cache-Control: max-age=60.
  7. 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).
  8. 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:

  1. Fetch pointerGET https://cdn.locationfinders.com/finders/{finderKey}/config.json (60s TTL). Returns { hash, variant, published_at }.
  2. Resolve payload — check localStorage key lf-cdn-v2:{finderKey} for a hash match; on miss, fetch https://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:

FileInvalidated on publish?Reason
finders/{key}/v/{hash}.jsonNoImmutable — a new publish creates a new URL; the old one is orphaned, not deleted
finders/{key}/config.jsonYesPointer must propagate immediately; 60s TTL alone would add up to 60s delay
ext/v2/snippet.js (widget build)YesWidget 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:

  1. Reads the build output from ext/2/dist/.
  2. Uploads each artifact to the R2 bucket under ext/v2/ path prefix.
  3. Sets appropriate Content-Type and Cache-Control headers per file type.
  4. 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_DISK is not set to r2, the payload will be written to local disk only. The CDN will not update. Verify FILESYSTEM_DISK=r2 and 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

AssetCDN TTLlocalStorageNotes
config.json pointer60 sPurged by backend on publish
v/{hash}.json payload1 year (max-age=31536000, immutable)Until hash changesNew publish = new URL; old URL orphaned
ext/v2/snippet.jsLong (CDN-managed)Purged by deploy-r2.mjs on widget release
Location imagesCDN-managedNo TTL override; CDN defaults apply
Origin fallbackNone (real-time)Laravel Cache store; used in dev + CDN miss