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:
- 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 durableurl/Gipity.fileUrlinstead.
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
Rangeheader, so audio/video seeking works. - Query options:
&variant=thumbnail(or another variant type) for a variant,&download=1to force a download instead of inline display (Content-Disposition: attachment, onHEADas well asGET, socurl -ILshows it). - It works everywhere: element sources (
<img>,<audio>,<video>,<a download>), navigations, cross-originfetch()/XHR from page JS (CORS-enabled - blob downloads, hashing, canvas export all work), andcurl/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:
// 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 itsguid- a fabricatedstorage_guidwon'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" }
type:thumbnail|text|text_pages.width/heightare null for text variants.urlis durable: a serve URL for private files, a permanent CDN URL for public ones. Find one withresult.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
thumbnailvariant 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 withstorage.readText(fileGuid)(see app-files-storage) - 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, soreadTextreturns emptypages(and the uploadwarningssay to OCR it, e.g. atesseractCPU 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.
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
- app-files - uploading:
Gipity.upload, folder drops, the upload endpoints, limits - app-files-storage - the
storagehelper in functions: save from a URL, check the uploader, delete, read PDF text - app-testing -
ctx.uploadfor tests that need a real file