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; the function-side storage helper (save from a URL, check the uploader, delete, read PDF text) is in app-files-storage.

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:

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.

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.

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:

// 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.

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:

{ "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" }

Types:

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.

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):

"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