docs: phone photo backup contract, and the file-sync decision
photo sync turned out to be mostly built already. officer proxies immich through
officer-photos, and that sidecar's allow-list already permits the `assets`
resource for GET/POST/PUT/DELETE — so immich's own upload and bulk-upload-check
endpoints are already reachable with officer's auth in front and the immich key
never leaving the sidecar. the deliverable is therefore the contract, not a new
ingest service.
three gaps are written down rather than papered over:
- immich is not currently connected in officer (_health says configured:false),
so none of it could be verified live. the api key moved out of .env and was
never re-entered in the ui. owner action.
- upload bodies are buffered twice, once in createSidecarProxy and once in the
photos sidecar. nothing fails at phone-photo sizes; a video library would be
unpleasant. fixing it touches the shared factory, so it is a decision.
- no resumable upload. immich's own app has the same limitation.
file sync is design-only, as agreed. the recommendation is syncthing supervised
as a sidecar rather than reimplementing nextcloud's sync protocol — a mount is
not a sync, and the local-first replica is the whole feature.
the iOS answer is stated plainly because it changes the design: continuous
background sync is not possible there, which is why nextcloud's own iOS app is
manual too. if iOS matters, webdav becomes first-class rather than optional, and
that is the owner's call to make before any code is written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
# Phone photo auto-backup — API contract
|
||||
|
||||
Written 2026-08-04, for the mobile developer. This is the server-side contract for "photos taken on the
|
||||
phone appear on the server automatically", i.e. what the Immich app does today.
|
||||
|
||||
**The mobile app is not built here.** This document is the boundary: everything below is either already
|
||||
live or explicitly listed as missing, so the app can be built against it rather than reverse-engineered
|
||||
from the web client.
|
||||
|
||||
---
|
||||
|
||||
## The short version
|
||||
|
||||
**Almost all of this already exists.** Officer proxies a self-hosted Immich through the `officer-photos`
|
||||
sidecar, and the sidecar's route allow-list already permits the `assets` resource for `GET POST PUT
|
||||
DELETE` (`src/servers/sidecar/photos/routes.ts:22`). Immich's own upload and dedupe endpoints are
|
||||
therefore already reachable through Officer, with Officer's auth in front and the Immich API key never
|
||||
leaving the sidecar.
|
||||
|
||||
So the work is not "build a photo ingest service". It is: use the endpoints below, and close the three
|
||||
gaps at the bottom.
|
||||
|
||||
---
|
||||
|
||||
## Topology
|
||||
|
||||
```
|
||||
phone app officer officer-photos Immich
|
||||
───────── ─────── ────────────── ──────
|
||||
Authorization: → userMiddleware (JWT) → allow-list by first → x-api-key
|
||||
Bearer <platform createSidecarProxy path segment + (held ONLY
|
||||
JWT> injects X-Officer-User method in the sidecar)
|
||||
```
|
||||
|
||||
The phone holds a **platform JWT** — the same credential `monorepo-mobile/packages/core/src/services/
|
||||
api.ts:58` already sends as `Authorization: Bearer <token>`. It never holds an Immich key and never
|
||||
talks to Immich directly.
|
||||
|
||||
Base path for everything below: **`/api/photos/_officer/…`**, which maps to `<immich>/api/…`.
|
||||
So `/api/photos/_officer/assets` is Immich's `POST /api/assets`.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### 1. Is the library reachable at all
|
||||
|
||||
```
|
||||
GET /api/photos/_health
|
||||
→ 200 { ok: true, configured: true, account: "<label>", version: "…", ms: 42 }
|
||||
→ 503 { ok: false, configured: false, error: "not connected" }
|
||||
→ 502 { ok: false, configured: true, error: "…" }
|
||||
```
|
||||
|
||||
`configured: false` means the owner has not connected an Immich account in Officer yet. Show that as a
|
||||
setup prompt, not an error — it is not something the app can fix.
|
||||
|
||||
### 2. Ask what the server already has ← this is what makes backup cheap
|
||||
|
||||
```
|
||||
POST /api/photos/_officer/assets/bulk-upload-check
|
||||
Content-Type: application/json
|
||||
|
||||
{ "assets": [ { "id": "<deviceAssetId>", "checksum": "<base64 sha1 of the file bytes>" }, … ] }
|
||||
|
||||
→ 200 { "results": [ { "id": "…", "action": "accept" | "reject", "reason": "duplicate" | …,
|
||||
"assetId": "<existing server id, when rejected as duplicate>" }, … ] }
|
||||
```
|
||||
|
||||
Call this **before** uploading anything. It is the difference between a backup pass costing a few KB and
|
||||
costing the whole camera roll. Batch generously — a few hundred ids per call.
|
||||
|
||||
`checksum` is base64-encoded **SHA-1** of the raw file bytes. Not SHA-256, not hex.
|
||||
|
||||
### 3. Upload one asset
|
||||
|
||||
```
|
||||
POST /api/photos/_officer/assets
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
assetData the file bytes (required)
|
||||
deviceAssetId stable per-device id for this photo (required)
|
||||
deviceId stable id for this device (required)
|
||||
fileCreatedAt ISO 8601 (required)
|
||||
fileModifiedAt ISO 8601 (required)
|
||||
isFavorite "true" | "false"
|
||||
duration for video
|
||||
livePhotoVideoId when pairing a live photo's video part
|
||||
|
||||
→ 201 { "id": "<asset id>", "status": "created" }
|
||||
→ 200 { "id": "<existing id>", "status": "duplicate" }
|
||||
```
|
||||
|
||||
`deviceAssetId` + `deviceId` is the identity pair Immich dedupes on, alongside the checksum. Keep both
|
||||
stable across app reinstalls if you can — if they change, the checksum still prevents duplicate _files_,
|
||||
but the server will not recognise the asset as one it has already seen from this device.
|
||||
|
||||
### 4. Albums (optional, for "put my backups in an album")
|
||||
|
||||
```
|
||||
GET /api/photos/_officer/albums
|
||||
POST /api/photos/_officer/albums { albumName, assetIds? }
|
||||
PUT /api/photos/_officer/albums/<id>/assets { ids: [...] }
|
||||
```
|
||||
|
||||
### 5. Reading the library back
|
||||
|
||||
```
|
||||
GET /api/photos/_officer/assets/<id>/thumbnail?size=preview|thumbnail
|
||||
GET /api/photos/_officer/assets/<id>/original
|
||||
POST /api/photos/_officer/search/metadata { … }
|
||||
GET /api/photos/_officer/timeline/…
|
||||
```
|
||||
|
||||
Range and `If-None-Match` are forwarded, so thumbnails cache and videos seek properly.
|
||||
|
||||
---
|
||||
|
||||
## The backup loop the app should implement
|
||||
|
||||
1. Enumerate new local assets since the last successful run (by creation date, per album/bucket).
|
||||
2. Compute SHA-1 for each; batch into `bulk-upload-check`.
|
||||
3. Upload only the `accept` ones, one at a time, honouring a user setting for "wi-fi only".
|
||||
4. Record the returned server id against the local asset id so step 1 can skip it next time.
|
||||
5. On failure, retry with backoff. **Do not** assume a failed upload means the asset is absent — re-run
|
||||
the check first; a request can succeed server-side and fail on the way back.
|
||||
|
||||
Immich's own app does essentially this, which is the reason to match it rather than invent something.
|
||||
|
||||
---
|
||||
|
||||
## Gaps — what is NOT done, and who has to close it
|
||||
|
||||
### A. Immich is not currently connected in Officer _(owner action, blocks live testing)_
|
||||
|
||||
`GET /api/photos/_health` returns `configured: false` right now. The Immich container is running
|
||||
(`immich_server`, port 2283) but Officer has no API key stored for it — the key used to live in `.env`
|
||||
as `IMMICH_API_KEY` and was correctly removed when credentials moved into the sidecar's own storage.
|
||||
|
||||
To close: in Immich, Account Settings → API Keys → new key; in Officer, `/photos` → connection settings
|
||||
→ paste URL + key. **Until this is done none of the endpoints above can be tested end to end**, which is
|
||||
why this document describes them from the code rather than from a live capture.
|
||||
|
||||
### B. Upload bodies are fully buffered, twice _(server work, needs a decision)_
|
||||
|
||||
`createSidecarProxy` does `await ctx.req.arrayBuffer()` (`create-proxy.ts:103`) and the photos sidecar
|
||||
does it again (`photos/routes.ts:93`). A 4K video is therefore held in memory twice per upload. Limits
|
||||
are generous (server 50 GB, photos sidecar 4 GB) so nothing will _fail_ at phone-photo sizes, but a
|
||||
backup pass over a large video library will make the platform's memory graph unpleasant.
|
||||
|
||||
The fix is to stream the body through instead of buffering, which touches the shared proxy factory and
|
||||
so affects every sidecar. Worth doing before heavy use; not worth blocking the app on.
|
||||
|
||||
### C. No resumable upload _(server work, needs a decision)_
|
||||
|
||||
An interrupted upload restarts from zero. On mobile data that is a real cost for videos. Immich's own
|
||||
app has the same limitation, so matching it is defensible for a first version — but if it turns out to
|
||||
matter, this is the piece that has to be designed rather than borrowed.
|
||||
|
||||
---
|
||||
|
||||
## Things deliberately not exposed
|
||||
|
||||
The sidecar's allow-list refuses Immich's `admin/*`, `auth/*`, `oauth/*`, `api-keys/*`, `sessions/*`,
|
||||
`jobs/*`, `system-config/*`, `system-metadata/*` and `libraries/*`. A proxy that can mint its own
|
||||
credentials is not a proxy. If the app genuinely needs something behind that line, it should be a
|
||||
decision, not a widened regex.
|
||||
Reference in New Issue
Block a user