{
  "name": "app-files-display",
  "title": "App API - Displaying Files & Variants",
  "description": "Show uploaded files in a deployed app: durable serve URLs for <img>/<audio>/downloads, share links for signed-out viewers, revoking URLs, thumbnails, PDF text variants, audio tags",
  "guid": "sk_plat_afdp",
  "category": "App services",
  "requiredTools": [],
  "content": "# App API - Displaying Files & Variants\n\nHow 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).\n\n## The durable URL\n\nThe `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:\n- **public** files → a permanent CDN URL (`media.gipity.ai/...`)\n- **private** files → a branded serve URL that re-signs storage on every request\n\n> 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.\n\n```js\nimg.src = await Gipity.fileUrl(fileGuid);                          // full file\nthumb.src = await Gipity.fileUrl(fileGuid, { variant: 'thumbnail' }); // 200x200 image thumb\n```\n\nFor `public: true` files, `Gipity.upload(...)` already returns a usable `result.url`. `Gipity.fileUrl` is for private files or a variant URL.\n\n**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`.\n\n### The serve URL (`files/:guid/serve`)\n\nThis is what `Gipity.fileUrl` (client) and `storage.fileUrl` (functions) return for a private file. You don't build it by hand.\n\n- **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.\n- **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.\n- **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).\n- **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`.\n- **It stops working** only when the file is deleted or its URLs are revoked (`storage.revokeFileUrl`, below).\n\n## Serving private files to anonymous viewers (share links)\n\nA `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:\n\n```js\n// functions/share-resolve.js  (auth: public)\nexport default async function (ctx, { db, storage }) {\n  const link = await db.findOne('share_links', { short_name: ctx.query.s });\n  if (!link) return { error: 'not found' };\n  const file = await db.findOne('files', { short_guid: link.file_guid });\n  return {\n    name: file.filename,\n    url: await storage.fileUrl(file.file_guid),                 // streamable, durable\n    download_url: await storage.fileUrl(file.file_guid, { download: true }),\n  };\n}\n```\n\n`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.\n\n> **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).\n\n## Variants\n\nVariants 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.\n\nEach `variants` element:\n\n```json\n{ \"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\" }\n```\n\n- `type`: `thumbnail` | `text` | `text_pages`. `width`/`height` are null for text variants.\n- `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')`.\n\nTypes:\n- **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.\n- **text** - PDFs get their whole-document text extracted to plain text.\n- **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).\n\nVariant 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.\n\n### When there is no thumbnail\n\n**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.\n\n```js\nconst thumb = result.variants.find((v) => v.type === 'thumbnail');\nimg.src = thumb ? thumb.url : (result.content_type.startsWith('image/') ? result.url : '/img/file-icon.svg');\n```\n\n## Audio tags\n\nFor 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):\n```json\n\"audio\": {\n  \"artist\": \"Daft Punk\", \"album\": \"Discovery\", \"genre\": \"House\",\n  \"title\": \"One More Time\", \"track_number\": 1,\n  \"track_seconds\": 320, \"duration_ms\": 320373, \"bitrate\": 320, \"year\": 2001\n}\n```\nPersist 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.\n\n## Related Skills\n\n- [app-files](app-files.md) - uploading: `Gipity.upload`, folder drops, the upload endpoints, limits\n- [app-files-storage](app-files-storage.md) - the `storage` helper in functions: save from a URL, check the uploader, delete, read PDF text\n- [app-testing](app-testing.md) - `ctx.upload` for tests that need a real file\n"
}
