# R2 Offload — Project Notes (CLAUDE.local.md)

WordPress media-offload plugin. Started GCS-only ("R2" name was aspirational); now
**multi-cloud**. Offloads the media library to a remote bucket, rewrites front-end
URLs to a CDN, and migrates large libraries in resumable batches. Byte-preserving
(no re-encoding; uploads existing webp/avif sidecars). Also offloads configured
extra uploads folders (BuddyBoss `avatars/`, `bb_medias/`, etc.) via path-based
queue items and rewrites those URLs when serving from remote.

## Supported providers

| id      | Label                   | Transport | Auth                                  | Public URL |
|---------|-------------------------|-----------|---------------------------------------|------------|
| gcs     | Google Cloud Storage    | Google SDK| service-account JSON                  | storage.googleapis.com/<bucket> |
| s3      | Amazon S3               | SigV4 HTTP| access_key + secret_key + region      | <bucket>.s3.<region>.amazonaws.com |
| r2      | Cloudflare R2           | SigV4 HTTP| access_key + secret_key + account_id  | **needs** cdn_domain (r2.dev/custom) |
| b2      | Backblaze B2            | SigV4 HTTP| access_key(keyID) + secret + region   | s3.<region>.backblazeb2.com/<bucket> |
| wasabi  | Wasabi                  | SigV4 HTTP| access_key + secret_key + region      | s3.<region>.wasabisys.com/<bucket> |
| spaces  | DigitalOcean Spaces     | SigV4 HTTP| access_key + secret_key + region      | <bucket>.<region>.digitaloceanspaces.com |
| bunny   | Bunny.net Edge Storage  | HTTP API  | secret_key = storage-zone password    | **needs** cdn_domain (pull zone b-cdn.net) |

S3-family + Bunny use the **WordPress HTTP API** (`wp_remote_request`) — no SDK,
no `composer install` needed. Only GCS needs `google/cloud-storage`.

## Architecture

`Plugin` is a tiny service registry booted on `plugins_loaded`. Storage is
resolved at boot by **`Storage\Storage_Factory::make()`** from `settings->provider()`.

- `Storage\Storage_Client` — interface every backend implements
  (`is_available/upload_file/head_object/download_object/delete_object/test_connection`).
- `Storage\GCS_Client` — Google SDK wrapper.
- `Storage\S3_Compatible_Client` — one client for s3/r2/b2/wasabi/spaces. Path-style
  addressing (`{endpoint}/{bucket}/{key}`). Signs with `Storage\S3_Signer` (hand-rolled
  SigV4, validated against the AWS doc test vector). Re-signs per retry (fresh x-amz-date).
- `Storage\Bunny_Client` — Bunny's own API (single `AccessKey` header). `head_object`
  works by listing the parent dir (Bunny has no HEAD).
- `Storage\Rate_Limiter` — throttle + exponential backoff. `wrap()` retries when the
  thrown exception's `getCode()` is 0 (transport) / 408 / 429 / 5xx. Clients throw with
  the HTTP status as the code so retry classification works; terminal 2xx/4xx are returned.
- `Storage\Providers` — **single source of truth**: per-provider label, type, endpoint
  & public-url templates, regions, list pricing, signup guide steps, pros/cons. Add a
  provider here first.
- `Storage\Cost_Estimator` — estimates monthly + one-time cost from stored bytes + object
  count using the `Providers` pricing tables. `estimate_all()` ranks every provider.
- `Media\*` — Offloader (hooks `wp_generate_attachment_metadata` → queue → `process_attachment`),
  URL_Rewriter (request-cached `is_offloaded`), Path_Resolver, Pruner.
- `Migration\*` — Scanner (chunked), Migrator (claim/process/recover queue), Verifier.
- `Admin\Admin` — provider-aware settings form (JS toggles fields by `data-prov`).
  `Admin\Providers_Page` — guides + pros/cons + live cost estimator.
- DB tables: `*_r2_offload_items` (provider column now populated), `_files`, `_logs`.

## Settings keys (option `r2_offload_settings`)

`provider`, `bucket`, `object_prefix`, `cdn_domain`, `credentials_json` (gcs),
`access_key`/`secret_key`/`region`/`account_id` (s3-family; `secret_key` also = Bunny
zone password), `bunny_storage_zone`/`bunny_region`, behavior flags, `rate_ms`/
`max_retries`/`backoff_base_ms`, `est_monthly_egress_gb`. **Secret key is never echoed
back** — blank on save keeps the stored value.

## Adding a provider

1. Add a method + entry in `Storage\Providers` (type, endpoint/public-url templates,
   fields, regions, pricing, guide, pros/cons).
2. If it's S3-compatible, that's usually it — `S3_Compatible_Client` + factory handle it.
   Otherwise add a client implementing `Storage_Client` and a `case` in `Storage_Factory`.
3. Add field rows in `Admin::render_settings` with the right `data-prov` list.

## Gotchas / decisions

- R2 always signs region `auto` (region field hidden; forced in `handle_post` and in
  `S3_Compatible_Client::region()`). R2 + Bunny have **no public bucket URL** → CDN
  domain is required for URL rewriting (rewriter no-ops when base URL is empty).
- Uploads read the whole file into memory (`file_get_contents`) — fine for images; would
  need multipart for very large objects.
- `on_delete` falls back to **metadata-derived** object keys when local files were pruned,
  so remote objects aren't orphaned. There is still no stored per-object key map — a
  rename/move of the original after offload could miss derivatives. Roadmap: use the
  `_files` table as the authoritative key list.
- `wp_remote_retrieve_headers()` may return an object OR array → both clients guard with
  `headers_array()` before `getAll()`.
- Verifier/Pruner use the prefix **stored on the item row** (fallback: current effective
  prefix), so changing the prefix later doesn't break verification.

## Verify locally

- `php -l` over `includes/**` + root — must be clean.
- SigV4 sanity: the signer reproduces AWS's documented GET vector
  (`Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41`).
- In WP admin: Settings → pick provider → Save & Test Connection. Migration page to
  bulk-offload; Providers & Pricing page for guides + cost estimate.

## Pricing tables

List prices captured for **estimation only** (not quotes) in `Providers::*['pricing']`.
Revisit periodically; providers change tiers. Hidden-cost notes (min storage GB/days,
egress, request fees, base/min spend) are surfaced by `Cost_Estimator::hidden_costs()`.
