Storage & Export
Where uploads and exports go — memory, a local directory or Cloudflare R2 — and the formats you can export to.
Two separate questions
- Where do uploaded images live while you edit? — the asset backend, chosen by
PHOTO_STORAGE_BACKEND. - 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
# 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:
[{"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
| 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, 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:
| 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 Origins |
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=1for 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:
- /guide/storage-export/llms.txt — LLM-friendly documentation
- /sitemap.xml
- /robots.txt