# Accounts & Catalogue

> Sign in with Clerk and Photo Slop keeps your uploads, saved images and projects in a catalogue only you can read.

**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  


---



## What an account gives you

The editor works for anyone. Signing in adds a **catalogue** at [/catalog](/catalog): everything you keep is stored with your account and served to your session alone.

| Kind | How it gets there | What is stored |
|------|-------------------|----------------|
| **Source** (upload) | Drop an image on the canvas, or *Add images*, while signed in | The file (converted to PNG/JPEG for the browser when it arrived as TIFF, HEIC, …), the original when converted, a thumbnail. Sources are kept so your projects can reopen; they are counted in the summary but **not shown as cards** — a saved canvas is one card however many pictures it holds. `/photo/api/catalog?kind=upload` lists them |
| **Project** | *Save to catalogue* in the editor's Catalogue tab, or any export with *Keep in my catalogue* ticked (off by default for a download, ticked for you when Cloudflare R2 is the destination) | The PNG render **and** the [document](/guide/document-model) JSON — every layer with its position, text, effects and crop — the way an `.excalidraw` file travels beside its PNG. The render is what *Download* gives you; the document is what *Open in editor* loads |
| **Image** | The API only: `export.render` with `dest: catalog` and no document | A render without a document, a thumbnail |

From the catalogue page, *Download* fetches the item's image as a file through the app (`/photo/api/catalog/<id>/file`, owner-checked), *Open in editor* loads a project so you pick up exactly where you left off (`/editor?project=<id>`), and *Add to canvas* places an upload or image on the current canvas (`/editor?add=<id>`). The editor's own Catalogue tab lists your most recent items with the same actions. Delete removes the item and its files.

An export to *Cloudflare R2* (or, through the API, `dest=disk`) also writes the document beside the image, as `<same name>.json` in the same folder, so a file you find in a bucket or a directory a year from now can still be opened: `document.load` takes it as-is.

## Signing in

Authentication is [Clerk](https://clerk.com) through `dash-clerk-auth`, exactly as the 2plot network's other sites use it: the site is a Clerk *satellite* of `2plot.dev`, so one account signs you into every site in the network. The header shows the sign-in control when Clerk is configured; the catalogue page shows a sign-in card to signed-out visitors.

Server configuration, all in the environment (see `.env.example`):

| Variable | Purpose |
|----------|---------|
| `CLERK_SECRET_KEY`, `CLERK_PUBLISHABLE_KEY`, `CLERK_SIGN_IN_URL` | Turn Clerk on. With any of them unset the site runs without accounts and the catalogue is unavailable |
| `CLERK_FRONTEND_API`, `CLERK_IS_SATELLITE`, `CLERK_SATELLITE_DOMAIN`, `CLERK_SIGN_UP_URL`, `CLERK_SATELLITE_SIGN_IN_REDIRECT` | Satellite mode for a production host on `*.2plot.dev` |
| `SESSION_SECRET` | Signs the session cookies; required in production |
| `PHOTO_DEV_USER` | **Local work only.** A stand-in owner so the catalogue can be used without Clerk keys. Ignored when Clerk is on and ignored in production (`RENDER` or `APP_ENV=production`) |

## Where your files live

Every signed-in user owns one key prefix in the asset backend ([Storage & Export](/guide/storage-export)):

```text
users/<owner>/catalog.json                 the index of this user's items
users/<owner>/assets/<yyyy>/<mm>/<id>.jpg   uploads (+ .thumb.webp, + .orig.heic when converted)
users/<owner>/exports/<yyyy>/<mm>/<id>.png  saved images
users/<owner>/projects/<yyyy>/<mm>/<id>.png saved projects (+ <id>.json, the document)
```

The index is a small JSON file kept **in the same backend as the bytes**, so the catalogue works identically on a local directory and on Cloudflare R2 and needs no database. The trade-off: it is written last-writer-wins per user, so two tabs saving in the same instant can lose one entry. A catalogue holds up to 2000 items.

The owner id is derived from the Clerk user id (made key-safe: `user_2abc…` becomes `user-2abc…` plus a short fingerprint), never from an email address, so a changed email never orphans a catalogue.

## Who can read what

Access is decided **per file, on the server**, every time:

- A request for `/photo/files/users/<owner>/…` is served only when the request's signed-in user *is* that owner. Anyone else — signed out, or another user — receives **404**, so the existence of a file is not disclosed to someone who guesses a URL.
- Your files are always addressed through the app (`/photo/files/users/…`), on every backend — even R2 with a public domain configured — and the app streams the bytes after the owner check. That is what lets the editor draw them from any origin without a CORS rule on the bucket. Public (signed-out) uploads and `exports/` keep the bucket's URL.
- On the R2 backend the same check runs **before** the app issues a short-lived signed URL. Set `R2_PUBLIC_BASE` only for a bucket you mean to be public: with it set, URLs point straight at the bucket and bypass the app.
- Anonymous uploads go to the shared `assets/` prefix, are catalogued nowhere, and are readable by anyone with the (random) URL, as before.
- The browser is never asked who it is. Identity is the Clerk session cookie, verified by `dash-clerk-auth` on each request, on Flask and FastAPI alike.

Browsers cache a user's own files privately (`Cache-Control: private`); shared caches never see them.

## The API

The catalogue page and the editor's tab are thin views over a small JSON API. Every route answers `401` without a signed-in user.

| Route | What |
|-------|------|
| `GET /photo/api/catalog` | `{ok, owner, profile, summary, items[]}` — newest first |
| `GET /photo/api/catalog/<id>` | One item |
| `GET /photo/api/catalog/<id>/document` | A project's document JSON |
| `PATCH /photo/api/catalog/<id>` with `{"name": "…"}` | Rename |
| `DELETE /photo/api/catalog/<id>` | Remove the item and its files |
| `POST /photo/api/export` with `dest=catalog` | Save a render; add `name`, and `document` (the JSON) for a project |

From the editor, saving is the ordinary [`export.render`](/guide/commands#export) command:

```json
{ "type": "export.render", "payload": { "format": "png", "dest": "catalog", "name": "Summer", "project": true } }
```

The acknowledgement's `server` object carries the new item's `id`, `kind`, `url` and `thumbUrl`. An agent with a signed-in browser tab can therefore save its work to the user's catalogue like any other export.

## Lifespans

Everything in a catalogue expires — **30 days** after it was saved for a member, **90** for the site owner (`PHOTO_CATALOG_TTL_DAYS` / `PHOTO_CATALOG_TTL_OWNER_DAYS`; `0` switches it off). Each card says when it goes, the header says so too, and expired items are removed with their files the next time the catalogue is read, by the hourly sweep `run.py` starts, or by `python3 scripts/catalog_purge.py`. Download what you want to keep. The rail exists so an abandoned account cannot fill the bucket for ever; the full set of rails — the spend cap, the owner-only model, the storage accounting — is on [Usage, limits & costs](/guide/usage).

## Running it locally

```bash
PHOTO_DEV_USER=me PHOTO_STORAGE_BACKEND=disk python run.py
```

Open [/editor](/editor), drop an image, and it appears under [/catalog](/catalog). Switch `PHOTO_DEV_USER` to another name and the catalogue is empty: each stand-in is its own owner, which is also how the browser test lane (`tests/test_photo_browser.py`) exercises the isolation.


---

*Source: /guide/catalog*
