# App API - Displaying Files & Variants

How a deployed app shows a file it stored: the durable URL to put in `<img>`, `<audio>` or a download link, share links that work for signed-out viewers, and the variants (thumbnails, PDF text, audio tags) generated at upload. Uploading itself is in [app-files](app-files.md); the function-side `storage` helper (save from a URL, check the uploader, delete, read PDF text) is in [app-files-storage](app-files-storage.md).

## The durable URL

The `url` you get back from an upload is **durable and safe to persist** - store it on your record, in a share link, or render it straight into the page. It does not expire on its own and never exposes a storage hostname:
- **public** files → a permanent CDN URL (`media.gipity.ai/...`)
- **private** files → a branded serve URL that re-signs storage on every request

> The metadata/content endpoints are auth-gated (`X-App-Token`), so a bare `<img src="https://a.gipity.ai/api/<APP_GUID>/files/<guid>/content">` (cookie only, no header) is rejected. **That is the one wrong way to show a private file** - use the durable `url` / `Gipity.fileUrl` instead.

```js
img.src = await Gipity.fileUrl(fileGuid);                          // full file
thumb.src = await Gipity.fileUrl(fileGuid, { variant: 'thumbnail' }); // 200x200 image thumb
```

For `public: true` files, `Gipity.upload(...)` already returns a usable `result.url`. `Gipity.fileUrl` is for private files or a variant URL.

**Persist the file `guid`, the durable `url`, or both.** Old rows holding an expiring `...X-Amz-...` signed URL (the "Request has expired" bug) should be re-resolved from the `guid` via `Gipity.fileUrl` / `storage.fileUrl`.

### The serve URL (`files/:guid/serve`)

This is what `Gipity.fileUrl` (client) and `storage.fileUrl` (functions) return for a private file. You don't build it by hand.

- **Auth is the capability token in `?t=`**, *not* a header - so it drops straight into `<audio src>`, `<a download>`, or an anonymous share page, and resolves for signed-out viewers.
- **It never expires on its own.** The server re-signs storage on every hit and 302-redirects; the browser follows the redirect with its `Range` header, so audio/video seeking works.
- **Query options:** `&variant=thumbnail` (or another variant type) for a variant, `&download=1` to force a download instead of inline display (`Content-Disposition: attachment`, on `HEAD` as well as `GET`, so `curl -IL` shows it).
- **It works everywhere:** element sources (`<img>`, `<audio>`, `<video>`, `<a download>`), navigations, cross-origin `fetch()`/XHR from page JS (CORS-enabled - blob downloads, hashing, canvas export all work), and `curl`/`curl -I`.
- **It stops working** only when the file is deleted or its URLs are revoked (`storage.revokeFileUrl`, below).

## Serving private files to anonymous viewers (share links)

A `public`-auth function (e.g. a share-link or delivery resolver) can mint a durable, header-free URL for a privately-stored file with the `storage.fileUrl` helper - no sign-in needed by the viewer:

```js
// functions/share-resolve.js  (auth: public)
export default async function (ctx, { db, storage }) {
  const link = await db.findOne('share_links', { short_name: ctx.query.s });
  if (!link) return { error: 'not found' };
  const file = await db.findOne('files', { short_guid: link.file_guid });
  return {
    name: file.filename,
    url: await storage.fileUrl(file.file_guid),                 // streamable, durable
    download_url: await storage.fileUrl(file.file_guid, { download: true }),
  };
}
```

`storage.fileUrl(fileGuid, opts?)` takes `{ variant, download }`. The returned URL is permanent until you delete the file or call `storage.revokeFileUrl(fileGuid)` (which bumps a per-file epoch and invalidates every URL previously minted for it - call `storage.fileUrl` again for a fresh one). Store the file `guid`, never a baked-in signed URL.

> **Testing it:** to assert a function resolves a real serve URL, make a real file in the test with `ctx.upload(name, opts?)` (returns `{ guid, url, ... }`) and pass its `guid` - a fabricated `storage_guid` won't resolve. See [app-testing → Testing file serving](app-testing.md#testing-file-serving-with-ctxupload).

## Variants

Variants are generated **synchronously at upload-complete**, only for images, PDFs and audio **up to 10MB**. They're in the upload result's `variants` array immediately, or never (nothing materializes later). Thumbnails: images and audio with cover art only, never PDFs. Over 10MB: no variants at all.

Each `variants` element:

```json
{ "type": "thumbnail", "url": "https://a.gipity.ai/api/<APP_GUID>/files/<guid>/serve?t=...&variant=thumbnail", "content_type": "image/jpeg", "size_bytes": 8421, "width": 200, "height": 200, "status": "complete" }
```

- `type`: `thumbnail` | `text` | `text_pages`. `width`/`height` are null for text variants.
- `url` is durable: a serve URL for private files, a permanent CDN URL for public ones. Find one with `result.variants.find(v => v.type === 'thumbnail')`.

Types:
- **thumbnail** - Images resized and center-cropped to 200x200px (JPEG), any dimensions. Audio files with embedded cover art (ID3 APIC) also get a `thumbnail` variant from that art.
- **text** - PDFs get their whole-document text extracted to plain text.
- **text_pages** - PDFs also get a JSON array of per-page text: `[{ "page": 1, "text": "..." }, ...]`, 1-indexed. This is what a document-Q&A / RAG app indexes so an answer can cite **which file and page** it came from. **Read it server-side in a function with `storage.readText(fileGuid)`** (see [app-files-storage](app-files-storage.md#reading-pdf-text-storagereadtext)) - don't fetch the variant URL (functions can't reach `*.gipity.ai`) or relay it through the browser. A PDF with no text layer (a scan) gets **neither** text variant, so `readText` returns empty `pages` (and the upload `warnings` say to OCR it, e.g. a `tesseract` CPU job).

Variant files are also reachable at `GET /api/<APP_GUID>/files/:guid/variants/:type` (auth-gated, redirects to storage); for display use the durable variant `url` instead.

### When there is no thumbnail

**A file can legitimately have no thumbnail** - over 10MB, undecodable bytes, non-image, audio without cover art. When generation is skipped or fails, the upload result carries a `warnings` array saying why. So check `result.variants` before resolving: `Gipity.fileUrl` and `storage.fileUrl` with `{ variant: 'thumbnail' }` **throw** when the variant doesn't exist - fall back to the original file or a placeholder in your grid/list rendering.

```js
const thumb = result.variants.find((v) => v.type === 'thumbnail');
img.src = thumb ? thumb.url : (result.content_type.startsWith('image/') ? result.url : '/img/file-icon.svg');
```

## Audio tags

For audio uploads, the complete-response also carries a `data.audio` object with the parsed ID3/container tags (`null` when the file isn't audio or carries no readable tags):
```json
"audio": {
  "artist": "Daft Punk", "album": "Discovery", "genre": "House",
  "title": "One More Time", "track_number": 1,
  "track_seconds": 320, "duration_ms": 320373, "bitrate": 320, "year": 2001
}
```
Persist these onto your own record if you want a searchable/sortable music library - the platform returns them but does not store them on your app's tables for you.

## Related Skills

- [app-files](app-files.md) - uploading: `Gipity.upload`, folder drops, the upload endpoints, limits
- [app-files-storage](app-files-storage.md) - the `storage` helper in functions: save from a URL, check the uploader, delete, read PDF text
- [app-testing](app-testing.md) - `ctx.upload` for tests that need a real file

