|
| 1 | +# Image upload |
| 2 | + |
| 3 | +`view.image` is a presentation control for a `field.string` that stores an |
| 4 | +**opaque storage key**, not image bytes. The generated form renders a |
| 5 | +metadata-driven upload + crop control (`<ImageUpload>`) that hands the picked |
| 6 | +image to a consumer-supplied adapter and stores only the key the adapter |
| 7 | +returns. No image bytes ever cross the MetaObjects wire — the field, the |
| 8 | +generated Zod schema, and the REST payload all carry a plain string. |
| 9 | + |
| 10 | +This is a **TS-web-only** feature. The metamodel vocabulary (`view.image` and |
| 11 | +its five attrs) is registered by the `metaobjects-ui-web` provider and applied |
| 12 | +only in TypeScript; the non-TS ports carry a byte-identical mirror of the spec |
| 13 | +file for drift parity but never apply the provider, and none of them ship an |
| 14 | +upload widget. A `field.string` authored with a `view.image` child is, to a |
| 15 | +Java/Kotlin/C#/Python port, just a plain string field. |
| 16 | + |
| 17 | +## Authoring: `field.string` + `view.image` |
| 18 | + |
| 19 | +Attach a `view.image` child to a `field.string` field. All five attrs are |
| 20 | +optional. |
| 21 | + |
| 22 | +```jsonc |
| 23 | +{ "field.string": { |
| 24 | + "name": "coverKey", |
| 25 | + "@maxLength": 80, |
| 26 | + "children": [ |
| 27 | + { "view.image": { |
| 28 | + "@aspectRatio": 1.777, |
| 29 | + "@maxEdge": 2000, |
| 30 | + "@store": "photos", |
| 31 | + "@accept": ["image/jpeg", "image/png"], |
| 32 | + "@maxBytes": 10485760 |
| 33 | + }} |
| 34 | + ] |
| 35 | +}} |
| 36 | +``` |
| 37 | + |
| 38 | +Sigil-free YAML (see [yaml-authoring.md](yaml-authoring.md)): |
| 39 | + |
| 40 | +```yaml |
| 41 | +field.string: coverKey |
| 42 | +maxLength: 80 |
| 43 | +children: |
| 44 | + - view.image: |
| 45 | + aspectRatio: 1.777 |
| 46 | + maxEdge: 2000 |
| 47 | + store: photos |
| 48 | + accept: [image/jpeg, image/png] |
| 49 | + maxBytes: 10485760 |
| 50 | +``` |
| 51 | +
|
| 52 | +| Attr | Type | Purpose | |
| 53 | +|---|---|---| |
| 54 | +| `@aspectRatio` | double | Crop aspect ratio (width / height). Omit for a freeform crop. | |
| 55 | +| `@maxEdge` | int | Longest-edge bound in px for the re-encoded output. | |
| 56 | +| `@store` | string | Opaque storage-namespace hint passed to the upload adapter. This is a hint the adapter interprets — it is **not** infrastructure configuration, and MetaObjects never talks to a storage backend directly. | |
| 57 | +| `@accept` | string[] | Accepted MIME types. This is a client-side guard only — the server must re-enforce it. | |
| 58 | +| `@maxBytes` | int | Client-side size ceiling in bytes. Also a guard only — the server must re-enforce it. | |
| 59 | + |
| 60 | +## What the generated form renders |
| 61 | + |
| 62 | +The generated `<Entity>Form` dispatches a `view.image` field to `<ImageUpload>` |
| 63 | +wrapped in react-hook-form's `<Controller>` (a bound native `<input>` can't |
| 64 | +drive a file-upload control's value/onChange contract). The `Controller` and |
| 65 | +`ImageUpload` imports are emitted only when the entity has an image field. |
| 66 | + |
| 67 | +```tsx |
| 68 | +<Controller |
| 69 | + name="coverKey" |
| 70 | + control={form.control} |
| 71 | + render={({ field }) => ( |
| 72 | + <ImageUpload |
| 73 | + value={field.value as string | null} |
| 74 | + onChange={field.onChange} |
| 75 | + meta={{ |
| 76 | + aspectRatio: 1.777, |
| 77 | + maxEdge: 2000, |
| 78 | + store: "photos", |
| 79 | + accept: ["image/jpeg", "image/png"], |
| 80 | + maxBytes: 10485760, |
| 81 | + }} |
| 82 | + /> |
| 83 | + )} |
| 84 | +/> |
| 85 | +``` |
| 86 | + |
| 87 | +`<ImageUpload>` renders the current image (via the adapter's `imageUrl`) plus |
| 88 | +Upload/Replace/Remove actions. Picking a file opens a lazy-loaded crop UI |
| 89 | +(`react-easy-crop`); saving the crop re-encodes the selection to a bounded |
| 90 | +JPEG on a `<canvas>` (which strips EXIF as a side effect — see |
| 91 | +[Expected backend contract](#expected-backend-contract)) and hands the blob to |
| 92 | +the adapter. Only the resulting key is written back through `onChange`. |
| 93 | +`<ImageUpload>` is also usable standalone, outside a generated form. |
| 94 | + |
| 95 | +## Runtime wiring: the `ImageUploadAdapter` contract |
| 96 | + |
| 97 | +`<ImageUpload>` has no knowledge of the storage backend, upload endpoint, or |
| 98 | +auth — it reads an adapter from React context. Wrap your app (or the relevant |
| 99 | +subtree) in `<ImageUploadAdapterProvider>`: |
| 100 | + |
| 101 | +```tsx |
| 102 | +import { ImageUploadAdapterProvider } from "@metaobjectsdev/react"; |
| 103 | +import type { ImageUploadAdapter } from "@metaobjectsdev/runtime-web"; |
| 104 | +
|
| 105 | +const imageAdapter: ImageUploadAdapter = { |
| 106 | + async upload(blob, { store }) { |
| 107 | + const body = new FormData(); |
| 108 | + body.set("file", blob, "image.jpg"); |
| 109 | + if (store) body.set("store", store); |
| 110 | + const res = await fetch("/api/images", { method: "POST", body }); |
| 111 | + if (!res.ok) throw new Error(`upload failed: ${res.status}`); |
| 112 | + return (await res.json()) as { key: string }; |
| 113 | + }, |
| 114 | + imageUrl(key) { |
| 115 | + return `/api/images/${key}`; |
| 116 | + }, |
| 117 | +}; |
| 118 | + |
| 119 | +export function App() { |
| 120 | + return ( |
| 121 | + <ImageUploadAdapterProvider value={imageAdapter}> |
| 122 | + {/* ... your routes / generated forms ... */} |
| 123 | + </ImageUploadAdapterProvider> |
| 124 | + ); |
| 125 | +} |
| 126 | +``` |
| 127 | + |
| 128 | +The contract, exported as a type from `@metaobjectsdev/runtime-web`: |
| 129 | + |
| 130 | +```ts |
| 131 | +interface ImageUploadAdapter { |
| 132 | + upload(blob: Blob, opts: { store?: string }): Promise<{ key: string }>; |
| 133 | + imageUrl(key: string): string; |
| 134 | +} |
| 135 | +``` |
| 136 | + |
| 137 | +The library ships **no concrete adapter** — `upload`/`imageUrl` are the seam a |
| 138 | +consumer implements against its own backend. `useImageUploadAdapter()` reads |
| 139 | +the adapter from context and throws a descriptive error if called outside a |
| 140 | +`<ImageUploadAdapterProvider>`. |
| 141 | + |
| 142 | +## Expected backend contract |
| 143 | + |
| 144 | +The adapter is a thin client for a backend you own. That backend is expected |
| 145 | +to expose: |
| 146 | + |
| 147 | +1. **`POST` (multipart) → `{ key }`** — accepts the uploaded blob, stores it |
| 148 | + under an object key (optionally namespaced by the `store` hint), and |
| 149 | + returns that opaque key. The generated field stores exactly this string. |
| 150 | +2. **`GET <key>` → bytes** with an immutable cache header (e.g. |
| 151 | + `Cache-Control: public, max-age=31536000, immutable`) — a stored image's |
| 152 | + bytes never change in place; a replace produces a new key, so the old key's |
| 153 | + response can be cached forever. |
| 154 | +3. **A server-side EXIF re-check.** The client-side crop/upload path |
| 155 | + (`cropToBlob` / `reencodeJpeg` in `@metaobjectsdev/runtime-web`) re-encodes |
| 156 | + the selection through a `<canvas>`, which strips EXIF (orientation, GPS, |
| 157 | + camera metadata) as a side effect of the redraw. That is a client |
| 158 | + convenience, not a security boundary — a malicious or nonstandard client |
| 159 | + can send anything. The backend must independently strip or re-check EXIF |
| 160 | + (and re-validate MIME type / size) on every upload; never trust the client |
| 161 | + re-encode. |
| 162 | + |
| 163 | +MetaObjects does not generate or ship this backend — it is documented so an |
| 164 | +adapter can be implemented against any storage/serving stack. |
| 165 | + |
| 166 | +## CSP requirement: `img-src` must allow `blob:` |
| 167 | + |
| 168 | +`<ImageUpload>`'s local preview and the crop UI (`react-easy-crop`) render the |
| 169 | +picked file as a `blob:` object URL (`URL.createObjectURL`) before anything is |
| 170 | +uploaded. If your app sets a Content-Security-Policy, its `img-src` directive |
| 171 | +must include `blob:` or the local preview/crop will be blocked: |
| 172 | + |
| 173 | +``` |
| 174 | +Content-Security-Policy: img-src 'self' blob: https://your-image-host; |
| 175 | +``` |
| 176 | + |
| 177 | +## Styling: opt-in `form.css` |
| 178 | + |
| 179 | +Default styling for the generated form controls and `<ImageUpload>` ships as |
| 180 | +an optional CSS file: |
| 181 | + |
| 182 | +```ts |
| 183 | +import "@metaobjectsdev/react/form.css"; |
| 184 | +``` |
| 185 | + |
| 186 | +It targets stable class names (`metaobjects-form`, `metaobjects-field-*`, |
| 187 | +`metaobjects-image-*`, `metaobjects-form-submit`, …) at single-class |
| 188 | +specificity so a host app's reset styles win, and resolves colors through |
| 189 | +`--mo-*` CSS custom properties with fallback hex values — themable with zero |
| 190 | +configuration, override any subset. |
| 191 | + |
| 192 | +`react-easy-crop` is an **optional, lazy-loaded peer dependency** — it's only |
| 193 | +imported (via `React.lazy`) when `<ImageUpload>` actually renders the crop UI, |
| 194 | +so consumers without a `view.image` field never pay for it. Install it only if |
| 195 | +you author one. |
| 196 | + |
| 197 | +## Storage & wire contract |
| 198 | + |
| 199 | +- The field is a plain `field.string` (e.g. `@maxLength 80`); only the opaque |
| 200 | + key is stored in the column and travels on the wire — request bodies, |
| 201 | + response bodies, and the generated Zod schema all see a string, exactly |
| 202 | + like any other string field. |
| 203 | +- Image **bytes** flow app → adapter → backend only, over the separate |
| 204 | + multipart/GET endpoints described above. They never pass through a |
| 205 | + MetaObjects-generated route, schema, or the entity's own REST payload. |
| 206 | +- Replacing an image is a new upload producing a new key; nothing is mutated |
| 207 | + in place. |
| 208 | +- Because the stored value is an ordinary string, no other port needs image |
| 209 | + logic to interoperate with this field — a Java/Kotlin/C#/Python service |
| 210 | + reading or writing the same entity just sees a string column. |
| 211 | + |
| 212 | +## See also |
| 213 | + |
| 214 | +- [field-types.md](field-types.md) — `field.string` and the common field |
| 215 | + attribute reference |
| 216 | +- [../ports/typescript-client.md](../ports/typescript-client.md) — the |
| 217 | + broader React/TanStack client surface (`useEntityForm`, `<CurrencyInput>`, |
| 218 | + generated hooks/grids) that `<ImageUpload>` slots into |
| 219 | +- [yaml-authoring.md](yaml-authoring.md) — sigil-free YAML authoring front-end |
0 commit comments