# Storage & Export

> Where uploads and exports go — memory, a local directory or Cloudflare R2 — and the formats you can export to.

**Site index:** [https://photoslop.2plot.dev/llms.txt](https://photoslop.2plot.dev/llms.txt) — every page on this site, as Markdown.  
**Network index:** [https://2plot.dev/llms.txt](https://2plot.dev/llms.txt) — The 2plot network; start here to discover sibling sites.  
**Sibling sites:** 14 more in The 2plot network — listed in the site index above.  
**Sitemap:** https://photoslop.2plot.dev/sitemap.xml  


---



## Two separate questions

1. **Where do uploaded images live while you edit?** — the *asset backend*, chosen by `PHOTO_STORAGE_BACKEND`.
2. **Where does a finished export go?** — chosen per export: download (the default) or R2 in the editor; the API also accepts a server directory (`dest=disk`). R2 and the server directory need a signed-in account from a browser; a script without an Origin header is admitted on the deployment's bearer token (`PHOTO_API_TOKEN`), exactly as uploads are.

Both go over plain HTTP multipart (`/photo/api/assets`, `/photo/api/export`), not Dash callbacks, so large files never pass through Dash's JSON transport.

## Asset backends

| `PHOTO_STORAGE_BACKEND` | Stores | Use it for |
|-------------------------|--------|-----------|
| `memory` (default) | Process memory | Demos and tests. **Not for production** — it is per-process and lost on restart, so it breaks under multiple workers |
| `disk` | `PHOTO_DISK_DIR` (default `data/photo`) | A single server with a persistent volume |
| `r2` | Cloudflare R2 bucket | Production. Served via `R2_PUBLIC_BASE` or signed URLs — leave `R2_PUBLIC_BASE` unset when users keep private catalogues (see below) |

An unknown value raises at boot rather than silently falling back to memory.

### This server



```python
# File: docs/photo-storage/backend_status.py

"""What THIS server is storing into, read from the backend it actually built.

The Storage & Export guide (docs/photo-storage/photo-storage.md) embeds this module live, so the page
shows the running configuration rather than describing one: a `memory` row on a production host is
the misconfiguration this table exists to make visible.
"""
import dash_mantine_components as dmc

from lib.photo import storage

LABELS = {
    "name": "Backend",
    "survives_restart": "Survives a restart",
    "shared_between_workers": "Shared between workers",
    "serves_bytes": "Bytes served by this app",
    "root": "Directory",
    "bucket": "Bucket",
    "public_base": "Public base URL",
    "endpoint_host": "Endpoint",
    "max_total_bytes": "Memory cap (bytes)",
}


def status_rows():
    """One row per fact the backend reports about itself; booleans become Yes/No."""
    info = storage.backend().describe()
    rows = []
    for key, label in LABELS.items():
        if key not in info:
            continue
        value = info[key]
        if isinstance(value, bool):
            value = "Yes" if value else "No"
        rows.append(dmc.TableTr([dmc.TableTd(label), dmc.TableTd(dmc.Code(str(value)))]))
    return rows


component = dmc.Stack(
    [
        dmc.Text("Read from the backend this server built at boot (PHOTO_STORAGE_BACKEND).", size="sm", c="dimmed"),
        dmc.Table(
            [dmc.TableThead(dmc.TableTr([dmc.TableTh("Property"), dmc.TableTh("Value")])), dmc.TableTbody(status_rows())],
            withTableBorder=True,
            fz="sm",
        ),
    ],
    gap="xs",
)
```

    :defaultExpanded: false


### Cloudflare R2

R2 is reached through its S3-compatible API with `boto3`. Set:

| Variable | Meaning |
|----------|---------|
| `R2_ACCOUNT_ID` | Your Cloudflare account id (builds `https://<id>.r2.cloudflarestorage.com`) |
| `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | An R2 API token scoped to the bucket |
| `R2_BUCKET` | Bucket name |
| `R2_PUBLIC_BASE` (or `R2_PUBLIC_BASE_URL`) | *Optional.* Public bucket domain (`https://cdn.example.com`). When unset, files are served through short-lived signed URLs **after the app has checked who may read the key** — the only setting under which a user's catalogue stays private |
| `R2_ENDPOINT` | *Optional.* Override the endpoint (jurisdiction buckets, tests) |

The editor loads every image with `crossOrigin="anonymous"`. A signed-in user's files are streamed through the app (`/photo/files/users/…`, same-origin, owner-checked), so they need nothing from the bucket. For **public** keys — signed-out uploads and the `exports/` destination — the app answers `/photo/files/<key>` with a redirect to the bucket's domain, so **the bucket needs a CORS policy for every origin you use** (`127.0.0.1`, `localhost` and `0.0.0.0` are three different origins) or the browser refuses the image. Cloudflare dashboard → R2 → your bucket → Settings → CORS policy → Add:

```json
[{"AllowedOrigins": ["https://photoslop.2plot.dev", "http://localhost:8559"],
  "AllowedMethods": ["GET", "HEAD", "PUT"],
  "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 86400}]
```

`PUT` is only needed for browser-direct uploads through the presigned recipe. `scripts/r2_smoke.py` probes this with your site's `Origin` and prints the policy when the header is missing; it also sends a browser User-Agent, because a public domain behind Cloudflare's Browser Integrity Check answers library clients with 403 error 1010.

#### Proving it against your bucket

The test suite covers the R2 code with a fake S3 client. To prove the real thing, run the smoke script with the variables above set — it uploads a tiny PNG through the same `R2Backend` the app uses, checks `exists`, reads it back, follows the redirect URL the app would issue, performs a presigned browser-direct PUT, runs an export with `dest=r2`, and deletes everything it created:

```bash
.venv/bin/python scripts/r2_smoke.py          # prints OBSERVED beside EXPECTED per step; exit 0 = PASS
.venv/bin/python scripts/r2_smoke.py --keep   # leave the objects for a look in the Cloudflare dashboard
```

Secrets are never printed (ids are masked to four characters). The one thing it reports but cannot judge is the bucket's CORS header on a plain GET: whether the canvas can *draw* an image served from the bucket depends on the rule you set for your site's origin.

## Export formats

| Format | Rendered by | Notes |
|--------|-------------|-------|
| PNG, JPEG, WebP | Browser | Honours `quality` and `scale` |
| SVG | Browser | Served with a sandboxing `Content-Security-Policy` |
| TIFF, PDF, AVIF, GIF, BMP, ICO | Server (Pillow) | The browser renders a PNG; the server transcodes it |
| HEIC | Server | Requires `pillow-heif`; HEIC *uploads* are accepted when it is installed |

## Export destinations

| `dest` | What happens |
|--------|--------------|
| `download` | The browser saves the file |
| `disk` | Written under `PHOTO_EXPORT_DIR` (default `data/exports`) at `exports/<year>/<month>/<id>-<filename>` |
| `r2` | Uploaded to the bucket; the result carries a `url` (public or signed) |
| `catalog` | Kept in the signed-in user's [catalogue](/guide/catalog), under their own key prefix in the asset backend; with a `document` it becomes a reopenable project |

With a `document` field (the editor always sends one), `disk` and `r2` also write it as `<same stem>.json` beside the image, and `catalog: "1"` on any destination keeps a copy — render and document — in the signed-in user's catalogue as a project.

## Who may read a stored file

Anonymous uploads live under `assets/…` and are readable by anyone who has the URL (the keys are random). A signed-in user's files live under `users/<owner>/…` and `/photo/files/<key>` serves them **only to that user**: the owner is read from the request's Clerk session (never from the browser), and a mismatch answers 404. The same check runs before the app issues an R2 signed URL. See [Accounts & Catalogue](/guide/catalog).

## Safety

The write routes are public by design for a demo, so they are guarded:

| Setting | Default | Effect |
|---------|---------|--------|
| `PHOTO_MAX_UPLOAD_MB` | 64 | Per-file size cap |
| `PHOTO_MAX_TOTAL_MB` | 4096 | Total stored bytes |
| `PHOTO_MAX_PIXELS` | 200 000 000 | Decompression-bomb limit |
| `PHOTO_RATE_PER_MIN` | 40 | Requests per client per minute |
| `PHOTO_ALLOWED_ORIGINS` | same-origin | Comma-separated allowed `Origin`s |
| `PHOTO_API_TOKEN` | unset | When set, requests that carry no `Origin` header (curl, servers, agents) must send `Authorization: Bearer <token>`. Browsers are covered by the same-origin check instead |
| `PHOTO_STRIP_EXIF` | `0` | `1` strips EXIF (incl. GPS) from JPEG uploads |

SVG **uploads** are rejected, because an SVG is a script-capable document. Uploads are re-validated and decoded server-side; the declared file type is never trusted.

> **Note:** EXIF metadata is kept by default so photographers don't lose it. Set `PHOTO_STRIP_EXIF=1` for any deployment where uploaded images are shared publicly.


---

*Source: /guide/storage-export*
