# Media Library

The media library lets dealers upload images and PDFs for use across their landing pages. Images go to Cloudflare Images; PDFs live on the application server and are streamed through an API route. Uploads over 5MB are split into chunks client-side to work around Next.js dev proxy body size limits.

## Overview

Supported file types ([`lib/media-utils.ts:6`](../lib/media-utils.ts)):

| Type  | MIME types                                           | Max size | Storage                 |
| ----- | ---------------------------------------------------- | -------- | ----------------------- |
| Image | `image/jpeg`, `image/jpg`, `image/png`, `image/webp` | 10MB     | Cloudflare Images       |
| PDF   | `application/pdf`                                    | 25MB     | Local disk (`uploads/`) |

Assets are stored in the `MediaAsset` Prisma model ([`prisma/schema.prisma:249`](../prisma/schema.prisma)). For images, the `url` column holds the Cloudflare variant URL and `cloudflareId` holds the CF Images ID. For PDFs, `cloudflareId` is `null` and `url` holds a path relative to the project root (e.g. `uploads/documents/{dealerId}/{file}.pdf`).

PDFs are never served from `public/`. They are streamed through [`/api/documents/[id]`](../app/api/documents/%5Bid%5D/route.ts) so access can be tracked, `Content-Disposition` controlled, and files stored outside the statically-served directory.

## Upload flow: regular vs chunked

The client entry point is [`uploadFile()` in `lib/upload-file.ts:20`](../lib/upload-file.ts). A single check decides the path:

```ts
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
if (file.size > CHUNK_SIZE) return uploadChunked(...);
return uploadDirect(...);
```

### Direct upload (≤5MB)

Single `POST /api/media` with `FormData` containing `file`, `context`, and optional `alt`. See [`uploadDirect()` at `lib/upload-file.ts:26`](../lib/upload-file.ts) and [`POST /api/media` at `app/api/media/route.ts:76`](../app/api/media/route.ts).

### Chunked upload (>5MB)

Implemented in [`uploadChunked()` at `lib/upload-file.ts:41`](../lib/upload-file.ts):

1. Client generates a `uploadId` (`crypto.randomUUID()`) and slices the file into 5MB chunks.
2. Chunks uploaded **sequentially** via `POST /api/media/upload-chunk` with `chunk`, `uploadId`, `chunkIndex`.
3. After all chunks are uploaded, the client calls `POST /api/media` with `uploadId`, `totalChunks`, `fileName`, `fileType` (no `file` field). The server assembles.
4. Progress reserves 10% for the assemble step.

**Why chunking exists.** The Next.js dev proxy enforces a body size limit that kills large single-request uploads. Splitting into 5MB chunks stays under the limit. See commit `46e6220` ("fix: chunked uploads to bypass Next.js dev proxy body size limit") for context.

## Chunk assembly and cleanup

Chunks are written to `uploads/chunks/{dealerId}/{uploadId}/chunk-{idx}` ([`app/api/media/upload-chunk/route.ts:62`](../app/api/media/upload-chunk/route.ts)).

Server-side guards in [`app/api/media/upload-chunk/route.ts`](../app/api/media/upload-chunk/route.ts):

- `uploadId` must match `/^[a-zA-Z0-9-]+$/` (line 47)
- `chunkIndex` must be 0–99 - hard cap at 100 chunks (line 51)
- Per-chunk max 6MB (line 56) - slightly above the client's 5MB to absorb `FormData` overhead

Assembly in [`POST /api/media` at `app/api/media/route.ts:128`](../app/api/media/route.ts):

- `fileType` on the assemble request is re-validated against `ALLOWED_MEDIA_TYPES` - a client cannot lie about MIME type (line 133)
- `totalChunks` bounded to 1–100 (line 140)
- Each chunk is `stat`-ed before any are read into memory; total size is checked against `MAX_PDF_SIZE` before assembly (line 168)
- On success, `uploads/chunks/{dealerId}/{uploadId}` is removed (line 174)
- On any failure - missing chunk, oversize total, assembly error - the chunk directory is deleted (lines 154, 163, 177, 186)

Client-side cleanup: if a chunk upload fails mid-sequence, the client fires `DELETE /api/media/upload-chunk?uploadId=...` ([`lib/upload-file.ts:72`](../lib/upload-file.ts)) to remove partial chunks. The DELETE handler is idempotent ([`app/api/media/upload-chunk/route.ts:82`](../app/api/media/upload-chunk/route.ts)).

Orphaned chunk directories from abandoned uploads are not currently swept by a cron - if this becomes a disk-pressure issue, add a scheduled cleanup job.

## PDF serving via `/api/documents/[id]`

Added in commit `599d34a` ("fix: serve PDFs via API route instead of static files"). The route lives at [`app/api/documents/[id]/route.ts`](../app/api/documents/%5Bid%5D/route.ts).

**Why a route instead of `public/`:**

- PDFs are stored in `uploads/documents/{dealerId}/...` outside `public/`, so Next.js's static file handler cannot reach them.
- The route can enforce path-traversal guards, content-disposition, and per-asset cache policy.
- Filename returned to the browser is the dealer's original upload name, sanitized (`app/api/documents/[id]/route.ts:46`).

**Current response headers** ([`app/api/documents/[id]/route.ts:48`](../app/api/documents/%5Bid%5D/route.ts)):

```
Content-Type: application/pdf
Content-Disposition: inline; filename="<sanitized>"
Cache-Control: public, max-age=3600, must-revalidate
X-Content-Type-Options: nosniff
```

**If you change caching headers:** the PDF route sets `Cache-Control` explicitly. Do not assume a global cache-control middleware can replace this - the route must keep `must-revalidate` so that replaced PDFs (same asset ID, new content on disk) don't serve stale copies from intermediary caches. PDFs linked from dealer pages are public - no auth check is performed in the GET handler, which is intentional ([`app/api/documents/[id]/route.ts:14`](../app/api/documents/%5Bid%5D/route.ts)).

Note: there is a separate route at [`app/api/static/[...path]/route.ts`](../app/api/static/%5B...path%5D/route.ts) that serves dealer **HTML** files from `public/dealers/` - not PDFs. Don't confuse the two.

## Tier limits

Defined in [`lib/cms/types.ts:39`](../lib/cms/types.ts) and enforced in [`POST /api/media` at `app/api/media/route.ts:196`](../app/api/media/route.ts):

| Tier         | Image limit | PDF limit | Per-image max | Per-PDF max |
| ------------ | ----------- | --------- | ------------- | ----------- |
| starter      | 100         | 10        | 10MB          | 25MB        |
| growth       | 100         | 10        | 10MB          | 25MB        |
| enhanced     | 500         | 25        | 10MB          | 25MB        |
| professional | 1000        | 50        | 10MB          | 25MB        |

Lookups: [`getMediaLibraryLimit()`](../lib/cms/types.ts) at `lib/cms/types.ts:335` and [`getDocumentLibraryLimit()`](../lib/cms/types.ts) at `lib/cms/types.ts:343`.

Counts are split by MIME type via [`isPdfMimeType()`](../lib/media-utils.ts) at `lib/media-utils.ts:22`. Exceeding either limit returns 400 with a message that suggests upgrading.

Access to the library itself is gated by `hasMediaLibraryAccess()` - all tiers currently have access ([`lib/cms/types.ts:321`](../lib/cms/types.ts), `MEDIA_LIBRARY_TIERS`).

## Common pitfalls

**The 5MB threshold is silent.** Files at 5,242,880 bytes and below go through `uploadDirect`; anything above goes through the 3-request chunked flow. When debugging upload issues, check the actual byte size - off-by-one issues near the boundary (e.g. a ~5.0MB PDF) will hit different code paths on different machines depending on exact size. Both paths end in the same `POST /api/media`, but with different payloads and validation branches.

**PDF magic-byte validation happens only on the final `POST /api/media` call.** Chunks are not validated as PDF data individually - the assembled buffer is checked for `%PDF-` at `app/api/media/route.ts:254`. Don't add per-chunk validation; it will reject legal uploads.

**Cache headers and PDFs.** If you add a global `Cache-Control` header via middleware or `next.config.mjs`, verify it doesn't conflict with the PDF route's `must-revalidate`. A stale PDF served after a dealer replaces a document is a support-ticket generator. Similarly, do not add `immutable` anywhere that touches `/api/documents/*`.

**`MAX_PDF_SIZE` is the assembled-file limit.** The chunk size (6MB per chunk) is independent. Changing `MAX_PDF_SIZE` in [`lib/media-utils.ts:44`](../lib/media-utils.ts) without also updating the UI copy (`"Maximum is 25MB"` in multiple places) will confuse users.

**Dev proxy body limit.** If you ever see unexplained upload failures in dev around the 5–10MB range but not in production, the Next.js dev proxy (not the app) is the likely culprit. Don't add `experimental.proxyClientMaxBodySize` - that knob was removed in commit `ff5a97d`. Use the chunked path.

## Related

- [Plan feature matrix](./PLAN_FEATURES_MATRIX.md) - tier mapping for all feature limits
- [`lib/cloudflare-api.ts`](../lib/cloudflare-api.ts) - Cloudflare Images SDK wrapper
- [`prisma/schema.prisma:314`](../prisma/schema.prisma) - `MediaAsset` model
