# Asset storage on Cloudflare R2

Every asset the system stores — captain documents, vehicle photos, proof of
delivery, and anything uploaded through `FileService` — lives in a Cloudflare R2
bucket. This document explains the configuration, the three R2 behaviours that
differ from AWS S3, and the one setup step that is still outstanding.

## The short version

| | |
|---|---|
| Bucket | `captain-app` |
| Disk | `r2` in `config/filesystems.php` |
| Driver | `s3` — R2 speaks the S3 protocol |
| Packages | `league/flysystem-aws-s3-v3` 3.35, `aws/aws-sdk-php` 3.396 |
| Used by | `FILESYSTEM_DISK=r2` (FileService) and `MEDIA_CLOUD_DISK=r2` (medialibrary) |

Both storage paths end at R2. There is no third one.

## Environment

```dotenv
R2_BUCKET=captain-app
R2_ACCESS_KEY_ID=…
R2_SECRET_ACCESS_KEY=…
R2_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
R2_URL=
R2_SIGNED_URL_TTL=60

FILESYSTEM_DISK=r2
MEDIA_DISK=public
MEDIA_CONVERSIONS_DISK=r2
MEDIA_CLOUD_DISK=r2
```

`MEDIA_DISK=public` is not a mistake — see the next section. To move storage
back to this machine entirely, set `FILESYSTEM_DISK=local` and leave
`MEDIA_CLOUD_DISK` empty.

## Uploads do not happen in the request

**Measured, from a development machine:**

| | Time |
|---|---|
| First R2 call in a fresh PHP process | **2.6 – 4.2s** |
| Each call after it, same process | ~450ms |

PHP-FPM opens its own connection per request and has no pool across requests, so
**every** request that touches R2 pays that DNS and TLS handshake again. A
captain's registration uploads four files, so it paid the handshake and then a
round trip per file. Registration measured **15.6s on R2 against 2.6s on a local
disk**.

A queue worker is a long-lived process: it pays the handshake once and reuses the
connection for every job after. So uploads are staged and moved:

1. the request writes to `MEDIA_DISK` — the local disk, milliseconds — and
   answers;
2. `QueueMediaMoveToCloud` hears medialibrary's `MediaHasBeenAddedEvent` and
   queues `MoveMediaToCloudDisk`;
3. the worker streams the file to `MEDIA_CLOUD_DISK`, repoints `media.disk`, and
   deletes the staged copy.

Registration is back to **2.5 – 3.5s**, the local-disk figure.

**Nothing is unreachable in between.** A media row records the disk it is on, so
its URL resolves to local storage before the move and to R2 after. If the worker
never runs, the file stays local and still serves. The record is repointed only
after the bytes are on the bucket, and the staged copy is deleted only after
that, so an interrupted move loses nothing and a retry is a no-op.

Conversions go straight to R2 (`MEDIA_CONVERSIONS_DISK`) because they are already
generated in the worker, on the warm connection. The event fires before
medialibrary dispatches the conversion job, so the move runs first and the
conversion reads the original from R2.

**This requires a real queue driver and a running worker.** Under `sync` the move
runs inline and the request pays the upload anyway. Both are already true in
production (`queue:work` under systemd).

**One thing it assumes: a single application server.** The staged file is on the
machine that received the upload, so a worker on another machine cannot move it.
The job would fail and the file would stay local — it still serves, but it never
reaches the bucket. Revisit this before scaling to a second app server.

`FileService` uploads — currently only the admin profile photo — still write to
R2 inside the request and pay the handshake. That is a rare, admin-initiated
action, so it was left alone.

## Deletions do not happen in the request either

A delete costs the same connection setup as an upload, and it is easy to miss
because no file is being sent. It surfaced on **document resubmission**
(`POST /api/driver/documents`): the collections are `singleFile()`, so accepting
a new licence deletes the one it replaces.

| Resubmission | Time |
|---|---|
| Over an existing R2 document, deleting inline | 8.65s |
| Into an empty collection (nothing to delete) | 1.22s |
| Over an existing R2 document, deleting in a worker | **1.44s** |

`QueuedCloudDeletionMediaObserver` is wired in as medialibrary's
`media_observer`. It extends the stock observer and changes one thing: when a
deleted item's files are on the cloud disk, it queues
`DeleteMediaFilesFromCloudDisk` instead of removing them inline. Files on a local
disk are still deleted immediately — deferring a free operation buys nothing.

Deferring this is safe in a way deferring an upload is not: the media row is
already gone when the job runs, so nothing in the application can reach the
files. The worst a failure can do is leave bytes in the bucket that cost storage,
which is why the job logs rather than retries forever.

Media that straddles both disks — staged locally while its conversions already
went to the bucket — is handled: the local directory is removed inline and the
remote one queued. Note that the observer removes the local part directly rather
than calling the parent, because the parent deletes from *both* of a media
item's disks and would reach across to the bucket inline.

This applies to every deletion, not just resubmission: vehicle image updates,
proof-of-delivery replacement, and removing a captain all benefit.

## The outstanding setup step: `R2_URL`

**`R2_URL` is empty, and until it is filled in every asset is served through a
signed link that expires after an hour.** This works — it is verified — but it
is not how the system should run in production.

A bucket has two separate addresses and neither can be derived from the other:

- **`R2_ENDPOINT`** is the authenticated S3 API. It is what the application
  writes through. A plain browser `GET` against it is refused.
- **`R2_URL`** is the public read address: either the bucket's r2.dev
  development URL or a custom domain bound to it. It is off by default, and
  Cloudflare only creates it when someone enables it in the dashboard.

With `R2_URL` set, `Storage::url()` returns a stable public link that
Cloudflare's CDN caches and that R2 serves with no egress charge. Without it,
`App\Services\General\StorageUrlService` falls back to a presigned link so the
asset is at least readable.

Signed links are a safety net, not a destination. They cannot be cached by the
CDN or the browser, they change on every request, and they stop working after
`R2_SIGNED_URL_TTL` minutes — so an API response stored or cached for longer
than that will contain dead URLs.

**To finish the setup:** in the Cloudflare dashboard, open R2 → `captain-app` →
Settings, either enable the r2.dev development URL or connect a custom domain
such as `cdn.kapitano.shop`, then put that address in `R2_URL` and run
`php artisan config:clear`. Note that a public bucket is world-readable; if
captain licences and delivery photos must stay private, keep `R2_URL` empty and
leave the signed links in place. That is a policy decision, not a technical one.

## Three ways R2 is not S3

Each of these is configured deliberately in `config/filesystems.php`.

### 1. There is no region

R2 is not partitioned into regions, but the S3 signature algorithm requires one.
Cloudflare signs against the literal string `auto`, so the disk hardcodes it. A
real region name here fails with `SignatureDoesNotMatch`, which reads like a
wrong secret key and sends you looking in the wrong place.

### 2. Integrity checksums must be turned down

Since v3.337 the AWS SDK computes a CRC32 checksum on every upload by default
and sends it with `aws-chunked` transfer encoding. R2 does not implement that
encoding, so an ordinary upload fails with a `501`. We are on 3.396, so this is
live, and the disk sets:

```php
'request_checksum_calculation' => 'when_required',
'response_checksum_validation' => 'when_required',
```

### 3. There are no per-object ACLs

`PutObjectAcl` is not implemented. Verified against the live bucket:

- `putFileAs($path, $file, $name, ['visibility' => 'public'])` **works** — the
  ACL travels as a header on `PutObject` and R2 ignores it. This is the call
  `FileService::uploadFile()` makes, which is why it needed no change.
- `Storage::disk('r2')->setVisibility(...)` **fails** with
  `UnableToSetVisibility`.

Nothing in the application or in medialibrary calls `setVisibility` today. Do
not introduce one. Visibility on R2 is a property of the bucket, not the object.

## How a URL is produced

`App\Services\General\StorageUrlService` holds the single rule:

1. disk has a non-empty `url` → return the plain public URL;
2. otherwise, disk is an `s3` driver → return a presigned URL;
3. otherwise (a local disk) → return `Storage::url()` unchanged.

Two callers use it:

- `FileService::getUrl()`, for paths stored in a model column.
- `App\MediaLibrary\SignedFallbackUrlGenerator`, configured as medialibrary's
  `url_generator`. It extends `DefaultUrlGenerator` and overrides only the
  no-public-URL case, so behaviour on the local `public` disk is unchanged.

An empty string and a missing key mean the same thing. This matters: Laravel
treats an empty base URL as a real one and builds `/images/licence.jpg`, a URL
with no host, which resolves against the dashboard's own domain and 404s inside
an `<img>` tag without raising anything. `StorageUrlServiceTest` pins this.

A cache-busting `?v=` is never appended to a signed URL — the query string is
part of what was signed. `media-library.version_urls` is `false` anyway.

## Failures are loud

The `r2` disk sets `'throw' => true`, unlike the local disks which are silent.
A network disk genuinely fails sometimes, and `FileService::uploadFile()`
returns its path without checking the write, so a silent failure would hand back
a path to an object that does not exist. Writing to R2 raises instead.

## Tests never reach the bucket

`phpunit.xml` pins `FILESYSTEM_DISK=public`, `MEDIA_DISK=public`,
`MEDIA_CONVERSIONS_DISK=public` and an empty `MEDIA_CLOUD_DISK`. The media tests
call `Storage::fake('public')`, which fakes that disk and no other, so without
those pins every uploaded fixture would land in the real `captain-app` bucket.
The empty `MEDIA_CLOUD_DISK` also switches off staging, so a test's upload is
final when it returns and nothing waits on a worker. Leave all four in place.

Two suites cover this ground: `MediaCloudStagingTest` drives the staging and
move against fake disks, and `R2MediaRoundTripTest` does the whole journey
against the real bucket, gated behind `RUN_REAL_R2=1`:

```bash
RUN_REAL_R2=1 php artisan test --filter=R2MediaRoundTripTest
```

## Latency, measured

Round trip from a development machine in Syria to R2, per operation:

| Operation | Time |
|---|---|
| `put` | ~500 ms |
| `get` | ~540 ms |
| `exists` | ~235 ms |
| `delete` | ~350 ms |
| `temporaryUrl` | ~2 ms (local computation, no request) |

Two consequences worth knowing:

- `FileService::streamDownload()` calls `exists`, `mimeType`, `size` and
  `readStream` — four round trips, roughly 1.5 s before the first byte. It was
  written against a local disk. Serving assets from the public `R2_URL` instead
  of proxying them through the application avoids this entirely.
- `FileService::deleteFile()` calls `exists` then `delete`; the existence check
  costs a round trip and buys nothing, since deleting a missing key is not an
  error.

Neither is fixed here, because neither is on a hot path today.

## Known issue that R2 makes worse

`OrderService::markDelivered()` stores the proof-of-delivery file *before* the
transaction that checks whether the order may legally move, and the collection
is `singleFile()`. Re-delivering an already-delivered order with a different
photo returns 422 and still replaces the stored evidence.

On a local disk that is recoverable from a backup. R2 buckets have object
versioning **off** by default, so the overwritten photo is simply gone. Either
enable versioning on `captain-app` or fix the ordering before proof-of-delivery
photos become the system of record.
