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

Storage & Export

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


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_BACKENDStoresUse it for
memory (default)Process memoryDemos and tests. Not for production — it is per-process and lost on restart, so it breaks under multiple workers
diskPHOTO_DISK_DIR (default data/photo)A single server with a persistent volume
r2Cloudflare R2 bucketProduction. 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

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

VariableMeaning
R2_ACCOUNT_IDYour Cloudflare account id (builds https://<id>.r2.cloudflarestorage.com)
R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEYAn R2 API token scoped to the bucket
R2_BUCKETBucket 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_ENDPOINTOptional. 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:

[{"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:

.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

FormatRendered byNotes
PNG, JPEG, WebPBrowserHonours quality and scale
SVGBrowserServed with a sandboxing Content-Security-Policy
TIFF, PDF, AVIF, GIF, BMP, ICOServer (Pillow)The browser renders a PNG; the server transcodes it
HEICServerRequires pillow-heif; HEIC uploads are accepted when it is installed

Export destinations

destWhat happens
downloadThe browser saves the file
diskWritten under PHOTO_EXPORT_DIR (default data/exports) at exports/<year>/<month>/<id>-<filename>
r2Uploaded to the bucket; the result carries a url (public or signed)
catalogKept in the signed-in user's catalogue, 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.

Safety

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

SettingDefaultEffect
PHOTO_MAX_UPLOAD_MB64Per-file size cap
PHOTO_MAX_TOTAL_MB4096Total stored bytes
PHOTO_MAX_PIXELS200 000 000Decompression-bomb limit
PHOTO_RATE_PER_MIN40Requests per client per minute
PHOTO_ALLOWED_ORIGINSsame-originComma-separated allowed Origins
PHOTO_API_TOKENunsetWhen 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_EXIF01 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

Note for AI agents: This is the static, prerendered view of an interactive Dash application served because we detected a non-JS user agent. Full prose docs: