# Mobile Media Upload — Integration Guide

> The **one place** for how media uploads work end-to-end in the RN app:
> allowed types, size caps, the presign → PUT → reference flow, and the rules
> the backend enforces. Every media-bearing feature (posts, chat, onboarding,
> activities, achievements, events, avatar) uses the flow described here — the
> feature guides only add their own "attach/send" step and link back to this.

- API base URL (local): `http://localhost:3001`
- Auth: `Authorization: Bearer <accessToken>` on every API call (**required**)
  — onboarding uses the `onboardingToken` instead (see [§4](#4-onboarding-uploads))
- Header: `X-Client-Type: mobile` on every API call

> **⚠️ Separate repos — nothing is shared as a package.** The API's limits live
> in its `@ksn/validators` package; the RN app is a **different repository** and
> does **not** import it. The allowlist + caps below are **duplicated by hand**
> in the app — treat this doc as the contract and keep the app's copy in sync
> (see [§2](#2-the-rules-copy-into-the-app)). The server is always the source of
> truth: it re-checks everything and rejects mismatches.

---

## 1 — How uploads work (the whole model)

Media **never** flows through the API as bytes. The client uploads straight to
object storage using a short-lived **presigned URL**, then references the
uploaded object when it creates the post / message / etc.

```
1. POST  <presign endpoint>   → { uploadUrl, key/publicUrl }   (declare type + size)
2. PUT   <uploadUrl>          → upload raw bytes straight to storage
3. <feature create/send>      → reference the object (by key or url)
```

The server enforces uploads at **three layers** — know these so you handle the
errors correctly:

1. **Presign** — rejects a disallowed content-type (e.g. any video) with `415`,
   a `size` over the cap with `413`, and **bakes your declared `size` into the
   signed URL as `Content-Length`**. Your PUT body must be *exactly* that many
   bytes or storage returns `403`.
2. **Byte verification** — when you reference the object, the server downloads
   the first chunk and **sniffs the real bytes**. A file whose true content
   doesn't match what you declared (a `.png`-named PDF, a renamed video) is
   rejected `415`. **The declared type is never trusted** — don't try to sneak a
   type past it; send accurate metadata.
3. **Persistence** — a final image-only / kind check per surface.

There is a server-side multipart fallback (`POST /api/media/upload`) for clients
that can't presign — see [§6](#6-multipart-fallback). Prefer presign.

---

## 2 — The rules (copy into the app)

Mirror this in the RN app and keep it in sync with this doc. It's the same table
the backend enforces.

### Allowed content types + caps

| kind    | content types                                                        | max size |
| ------- | -------------------------------------------------------------------- | -------- |
| `image` | `image/jpeg`, `image/png`, `image/webp`                              | 10 MB    |
| `image` | `image/heic`, `image/heif` (+ `-sequence`) — iOS photos              | 15 MB    |
| `gif`   | `image/gif`                                                          | 15 MB    |
| `file`  | `application/pdf`, `.docx`, `.xlsx`, `text/csv`                      | 25 MB    |

**No video. No audio.** Any `video/*` or `audio/*` is rejected at presign
(`415`). Don't offer a record-video / record-voice / attach-video path.

`.docx` = `application/vnd.openxmlformats-officedocument.wordprocessingml.document`
`.xlsx` = `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`

### Per-surface rules — what each screen may attach

| Surface                         | Presign endpoint                     | Allowed kinds        |
| ------------------------------- | ------------------------------------ | -------------------- |
| **Posts**                       | `POST /api/media/presign`            | `image` only         |
| **Chat**                        | `POST /api/media/presign`            | `image`, `gif`, `file` |
| **Activities / Achievements**   | `POST /api/media/presign`            | `image` only         |
| **Events**                      | `POST /api/media/presign`            | `image` only         |
| **Onboarding** (avatar/act/ach) | `POST /api/onboarding/media-presign` | `image` only         |
| **Profile avatar**              | `POST /api/users/me/avatar/presign`  | `image` only         |

> `gif` in chat is usually a **Klipy picker** gif sent as `remoteUrl` (no
> upload). Owned gif uploads are also allowed. Files (pdf/docx/csv/xlsx) are
> **chat-only**. Everywhere else is images.

### Drop-in TypeScript (app-side copy)

```ts
// media-limits.ts — MIRRORS the API's @ksn/validators. Keep in sync with
// docs/mobile-guides/media-upload. The server is authoritative.
const MB = 1024 * 1024;

export type UploadKind = "image" | "gif" | "file";
export interface UploadTypeSpec { kind: UploadKind; maxBytes: number; ext: string[]; }

export const SUPPORTED_UPLOAD_TYPES: Record<string, UploadTypeSpec> = {
  "image/jpeg": { kind: "image", maxBytes: 10 * MB, ext: ["jpg", "jpeg"] },
  "image/png":  { kind: "image", maxBytes: 10 * MB, ext: ["png"] },
  "image/webp": { kind: "image", maxBytes: 10 * MB, ext: ["webp"] },
  "image/gif":  { kind: "gif",   maxBytes: 15 * MB, ext: ["gif"] },
  "image/heic": { kind: "image", maxBytes: 15 * MB, ext: ["heic"] },
  "image/heif": { kind: "image", maxBytes: 15 * MB, ext: ["heif"] },
  "image/heic-sequence": { kind: "image", maxBytes: 15 * MB, ext: ["heic"] },
  "image/heif-sequence": { kind: "image", maxBytes: 15 * MB, ext: ["heif"] },
  "application/pdf": { kind: "file", maxBytes: 25 * MB, ext: ["pdf"] },
  "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
    { kind: "file", maxBytes: 25 * MB, ext: ["docx"] },
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
    { kind: "file", maxBytes: 25 * MB, ext: ["xlsx"] },
  "text/csv": { kind: "file", maxBytes: 25 * MB, ext: ["csv"] },
};

// Which kinds each screen may attach.
export const SURFACE_KINDS = {
  post: ["image"],
  chat: ["image", "gif", "file"],
  generic: ["image"], // activities, achievements, events, onboarding, avatar
} as const;

export const specFor = (ct: string): UploadTypeSpec | null =>
  SUPPORTED_UPLOAD_TYPES[ct] ?? null;

/** Client-side pre-flight: block obvious rejects before you even presign. */
export function checkUploadable(contentType: string, size: number, surface: keyof typeof SURFACE_KINDS) {
  const spec = specFor(contentType);
  if (!spec) return { ok: false as const, reason: "type_not_supported" };
  if (!SURFACE_KINDS[surface].includes(spec.kind)) return { ok: false as const, reason: "kind_not_allowed_here" };
  if (size > spec.maxBytes) return { ok: false as const, reason: "too_large", maxBytes: spec.maxBytes };
  return { ok: true as const, kind: spec.kind, maxBytes: spec.maxBytes };
}
```

Pre-flight is UX only — it saves a round-trip and gives a nicer message. **The
server still enforces everything.**

---

## 3 — Standard upload: `POST /api/media/presign`

Used by **posts and chat** (and the activities/achievements/events attach
flows). Handles 1–4 files per call.

### 3.1 — Presign

```http
POST /api/media/presign
Authorization: Bearer <accessToken>
X-Client-Type: mobile
Content-Type: application/json

{
  "files": [
    { "filename": "photo.jpg", "contentType": "image/jpeg", "size": 348211 }
  ]
}
```

| field         | required | notes |
| ------------- | -------- | ----- |
| `filename`    | ✅ | Its extension sets the stored object's extension — pass a real one. |
| `contentType` | ✅ | Must be in the allowlist ([§2](#2-the-rules-copy-into-the-app)). Video → `415`. |
| `size`        | ✅ | **Exact** byte length you will PUT. Over the cap → `413`. Pinned as `Content-Length`. |

- Max **4** files per call.

**Response:**

```jsonc
{
  "success": true,
  "data": [
    {
      "uploadUrl": "https://.../signed-put-url",  // PUT bytes here (~5 min TTL)
      "publicUrl": "https://.../media/<you>-...",  // final URL (posts reference this)
      "key": "media/<yourUserId>-1719-uuid.jpg",   // storageKey (chat references this)
      "contentType": "image/jpeg"
    }
  ]
}
```

### 3.2 — PUT the bytes to storage

```http
PUT <uploadUrl>
Content-Type: image/jpeg      ← must equal the presign contentType
Content-Length: 348211        ← must equal the presign size (exact)

<raw bytes>
```

- **No auth headers** — the signed URL is the credential.
- **`Content-Length` must equal `size`.** Storage rejects any other length with
  `403`. Most HTTP clients set it automatically from a known-size body — see the
  RN note in [§7](#7-the-content-length-gotcha-read-this).
- On success storage returns `200` with an empty body.

### 3.3 — Reference it in the feature call

You **don't** call a separate attach endpoint for most flows — you pass the
reference inside the create/send body:

- **Posts** → `media: [{ "type": "image", "url": "<publicUrl>", "width", "height" }]`
  (image only). See [Posts guide](../post/mobile-posts-guide.md).
- **Chat** → `media: [{ "storageKey": "<key>", "kind": "image|gif|file", "mimeType", ... }]`
  (send the `key`, **not** a url; `mimeType` **required for files**). See
  [Chat Media guide](../chat/mobile-chat-media-guide.md).
- **Activities / Achievements / Events** → `media: [{ "type": "image", "url": "<publicUrl>" }]`.
  See [Activities & Achievements guide](../user-actions/mobile-activities-achievements-guide.md).

At this step the server **byte-verifies** each referenced object
([§5](#5-byte-verification-what-the-server-checks)).

---

## 4 — Onboarding uploads

Mid-onboarding you don't have an `accessToken` yet — use the **onboarding**
endpoint, authorized with the `onboardingToken`.

```http
POST /api/onboarding/media-presign
Authorization: Bearer <onboardingToken>
X-Client-Type: mobile
Content-Type: application/json

{
  "kind": "avatar",            // "avatar" | "activity" | "achievement"
  "filename": "avatar.jpg",
  "contentType": "image/jpeg",
  "size": 210433
}
```

**Image-only** (jpeg/png/webp 10 MB; heic/heif 15 MB). Response:
`{ success, data: { kind, uploadUrl, publicUrl, key, contentType } }`. PUT the
bytes exactly as [§3.2](#32-put-the-bytes-to-storage) (Content-Type +
Content-Length), then PATCH the session with the `publicUrl`. Full flow +
where each `publicUrl` lands in `data`: [Onboarding guide](../user-onboarding/mobile-onboarding-guide.md).

Errors specific to this route: `404` (session not active), `410 EXPIRED`.

---

## 5 — Avatar (logged-in)

```http
POST /api/users/me/avatar/presign
Authorization: Bearer <accessToken>

{ "contentType": "image/jpeg", "size": 210433 }
```

→ `{ success, data: { uploadUrl, publicUrl, key } }`. PUT the bytes (Content-Type
+ Content-Length), then **confirm**:

```http
PUT /api/users/me/avatar
{ "avatarUrl": "<publicUrl>" }
```

Confirm downloads the original, **byte-verifies it's an image**, generates the
square display + thumbnail (WebP), and sets `profile.avatarUrl`. Non-image → `415`.

---

## 6 — Multipart fallback: `POST /api/media/upload`

Only if you can't do presign → PUT. The API proxies the bytes (a hop the presign
flow avoids). `multipart/form-data`, up to **4** files.

- Same allowlist + caps + byte-verification as presign; video → `415`, over cap → `413`.
- Returns `{ success, data: [{ type, url, width, height, size, mimetype, altText }] }`
  where `type` is the server-verified kind (`image` | `gif` | `file`).

---

## 7 — Byte verification (what the server checks)

When you reference an uploaded object, the server sniffs its **real** bytes
before creating any row:

- **Type must match.** Declared `image/png` but the bytes are a PDF / video /
  executable → `415`. This is a security control — **send accurate metadata, and
  don't rename files to fake a type.**
- **Files: send the real `mimeType`.** CSV has no magic number, so for
  `kind: "file"` the server relies on your declared `mimeType` to validate it —
  **`mimeType` is required for file attachments.**
- **HEIC/HEIF** (iOS): a single photo can sniff as `image/heic`, `image/heif`,
  or a `-sequence` variant — all are accepted as images. Declare the actual
  `contentType` your picker reports.
- **Size is re-checked** against the cap even after upload.

> **Decode limit ≠ size limit.** Even a within-cap image can be rejected *after*
> upload if it exceeds the server's decode limits (8000×8000 / 32 MP) — e.g. a
> 9 MB 9500×9500 PNG. Down-scale huge images before upload.

---

## 8 — The Content-Length gotcha (read this)

The presigned URL is signed for **exactly** the `size` you declared. The PUT
must send a `Content-Length` equal to that `size`, or storage returns **`403`**
(it reads as a signature/length mismatch).

- Declare the **real** byte length at presign — get it from the picker/file
  system, not an estimate.
- In RN, `fetch` sets `Content-Length` automatically when the body is a `Blob`
  (or string) of known size. Upload a Blob, not a streaming source:

```ts
async function putToStorage(uploadUrl: string, fileUri: string, contentType: string) {
  const blob = await (await fetch(fileUri)).blob(); // known size → Content-Length set
  const res = await fetch(uploadUrl, {
    method: "PUT",
    headers: { "Content-Type": contentType }, // do NOT add Authorization
    body: blob,
  });
  if (!res.ok) throw new Error(`upload failed: ${res.status}`); // 403 = length/type mismatch
}
```

- **Don't** send the file via `FormData` to a presigned PUT — that changes the
  body length and content-type and breaks the signature.
- If your HTTP layer can't guarantee an exact `Content-Length`, use the
  [multipart fallback](#6-multipart-fallback) instead.

---

## 9 — End-to-end RN example (image on a post)

```ts
import { checkUploadable } from "./media-limits";
import { api } from "./api"; // your axios/fetch wrapper with auth + X-Client-Type

async function uploadPostImage(fileUri: string, contentType: string, size: number) {
  // 1. Pre-flight (UX only)
  const pre = checkUploadable(contentType, size, "post");
  if (!pre.ok) throw new Error(pre.reason);

  // 2. Presign
  const { data } = await api.post("/api/media/presign", {
    files: [{ filename: `photo.${pre.kind === "gif" ? "gif" : "jpg"}`, contentType, size }],
  });
  const { uploadUrl, publicUrl } = data.data[0];

  // 3. PUT bytes (exact Content-Length via Blob)
  await putToStorage(uploadUrl, fileUri, contentType);

  // 4. Reference on create (posts are image-only)
  return { type: "image", url: publicUrl }; // put into the post body's media[]
}
```

For chat, reference by `key` (not url) and send `kind` + `mimeType`:

```ts
const { key } = data.data[0];
// message media item:
{ storageKey: key, kind: "file", mimeType: "application/pdf", altText: "report.pdf" }
```

---

## 10 — Errors

| code | meaning | what to do |
| ---- | ------- | ---------- |
| 400  | bad request (missing url/key, empty file, message with neither text nor media) | fix the request shape |
| 401  | missing / invalid token | refresh auth |
| 403  | PUT `Content-Length` ≠ presigned `size` (or wrong `Content-Type`); or you don't own the object | re-presign with the exact size; PUT a Blob |
| 413  | declared `size` — or the stored bytes — exceed the per-type cap | compress / downscale; block in pre-flight |
| 415  | unsupported type (e.g. video/audio), or the **bytes don't match** the declared type | only offer allowed types; send accurate metadata |
| 410  | onboarding session expired (onboarding presign only) | restart the session |
| 429  | rate limited (e.g. chat send 60/min) | back off |

---

## 11 — Checklist

- [ ] Mirror [§2](#2-the-rules-copy-into-the-app) in the app; keep it in sync
      with this doc (separate repos — nothing is imported).
- [ ] Offer **only** allowed types per surface — **no video, no audio**; files
      (pdf/docx/csv/xlsx) in **chat only**; everything else image-only.
- [ ] Pre-flight type + `kind`-per-surface + size before presigning.
- [ ] Presign with the **exact** `size`; PUT a **Blob** so `Content-Length`
      matches; never add auth headers to the PUT; never use `FormData` for it.
- [ ] Reference correctly per surface: posts/activities/events → `{ type:"image", url }`;
      chat → `{ storageKey, kind, mimeType(for files), ... }`.
- [ ] Send accurate `contentType`/`mimeType` — the server byte-verifies and
      rejects spoofed types (`415`).
- [ ] Down-scale very large images (32 MP decode limit) before upload.
- [ ] Handle `413`/`415`/`403` with clear messages; treat returned `url`s as
      opaque + refetchable (don't persist them).
