Server-side work on an app's uploaded files: saving a file another service produced, checking who uploaded a file before your function trusts its guid, deleting files along with the records that point at them, and reading the text extracted from a PDF. Uploading from the browser is in app-files; showing files (durable URLs, share links, variants) is in app-files-display.
The helper
Every deployed function gets a storage helper in its services argument (always available, like db - no services: declaration needed):
await storage.importUrl(url, opts?); // → upload object { guid, name, size, content_type, url, ... } (save a file from a URL)
await storage.info(fileGuid); // → { guid, name, size, contentType, uploadedBy, ... } or null (who uploaded it)
await storage.delete(fileGuid); // → { deleted: true } (or false if not found)
await storage.readText(fileGuid); // → { text, pages: [{ page, text }], pageCount } (extracted PDF text)
await storage.fileUrl(fileGuid, opts?); // → durable access URL; opts { variant, download }
await storage.revokeFileUrl(fileGuid); // → { revoked: true } (invalidate minted URLs)
Every call is scoped to your app: a guid from another app (or another environment of this app) is not found. fileUrl and revokeFileUrl are covered with share links in app-files-display.
Linking an upload to a record (check the uploader)
The usual pattern: upload in the browser with Gipity.upload, then pass the returned guid to your function to save a row. That function must check the caller uploaded the file, or anyone who learns another user's guid can attach it to their own record, view it, and delete it.
storage.info(fileGuid) returns { guid, name, size, contentType, isPublic, uploadedBy, createdAt, width, height, table, recordId, variants }, or null when the guid isn't a live upload of this app. uploadedBy is the uploader's userGuid (compare it to ctx.auth.userGuid), null if they were signed out. Take name, size and type from it, not from the browser. variants lists the finished variants as objects, the same shape Gipity.upload returns them: [{ type, url, content_type, size_bytes, width, height, status }], so info.variants.find((v) => v.type === 'thumbnail')?.url is the thumbnail's durable URL (or undefined when there is none, e.g. a PDF or an image too large to thumbnail).
// functions/save-receipt.js (auth: user)
export default async function (ctx, { db, storage }) {
const { file_guid } = ctx.body;
const info = await storage.info(file_guid);
if (!info || info.uploadedBy !== ctx.auth.userGuid) return { error: 'File not found' };
const row = await db.insert('receipts', {
user_guid: ctx.auth.userGuid, file_guid, name: info.name, size: info.size,
});
return { ok: true, id: row.id };
}
Return the same "not found" for a missing guid and someone else's file, so the function doesn't confirm that a guid exists. To test this check, create the file as a fixture user with ctx.upload(name, { as: ctx.users.alice }) and call with ctx.fn.callAs(ctx.users.alice, ...); a plain ctx.upload has no uploader, so the check rejects it (see app-testing). A signed-out upload has uploadedBy: null and fails this check, so show the upload UI only after sign-in (see app-auth).
Deleting a file
End users delete through function code, with storage.delete(fileGuid). (DELETE /api/<APP_GUID>/uploads/:guid exists for the owner, project members and API keys only; visitors get 403.)
This permanently removes the stored bytes - the file and any thumbnail/text variants - from Gipity Storage, and marks the file deleted. It is scoped to your app (a function can only delete its own app's files) and idempotent: deleting an unknown or already-deleted guid returns { deleted: false } instead of throwing.
Delete the bytes whenever you delete the record that points at them - otherwise the file lingers in storage forever, leaking storage on every removal. The usual shape: the browser calls one of your functions, which removes both the record and the file.
// functions/delete-receipt.js (auth: user)
export default async function (ctx, { db, storage }) {
const { file_guid } = ctx.body;
const row = await db.findOne('receipts', { file_guid, user_guid: ctx.auth.userGuid });
if (!row) return { error: 'not found' };
await storage.delete(file_guid); // the bytes first (throws on failure)
await db.delete('receipts', { id: row.id }); // then your app-data row
return { ok: true };
}
File first, then row: if storage fails it throws and the row survives for a retry (a repeat storage.delete just returns { deleted: false }). The other order orphans the bytes.
// client - call your function; never try to delete files straight from the browser
const res = await Gipity.fn('delete-receipt', { file_guid });
if (res?.error) throw new Error(res.error); // a returned { error } is a failure too, not just a thrown one
file_guid is the guid returned from the upload (Gipity.upload(...) → result.guid) - the same value you stored on your record.
Reading PDF text (storage.readText)
storage.readText(fileGuid) reads a PDF's server-extracted text for document-Q&A / RAG. The platform auto-extracts per-page text at upload (the text and text_pages variants, see app-files-display); this pulls it straight from storage inside your function, so you index it server-side without a browser relay. Don't fetch the variant URL from a function: functions can't reach *.gipity.ai.
Returns { text, pages: [{ page, text }], pageCount } - text is the whole document, pages is per-page (1-indexed) for file + page citations. A file with no text layer (a scanned PDF, or a non-PDF) returns empty text/pages and pageCount: 0, so an ingest loop can skip it (OCR it separately, e.g. a tesseract CPU job; see jobs). PDFs over 10MB get no text variants, so they also come back empty.
The canonical ingest: user uploads a PDF → an ingest function calls storage.readText, chunks each page, and INSERTs rows with a full-text index → a query function searches those chunks and returns answers with their file/page. For meaning-based search over the chunks, embed them (see app-vector-search).
Saving a file from a URL (webhooks, finished renders)
storage.importUrl(url, opts?) downloads a file on the server and stores it in your app's Gipity Storage in one call - the backend way to keep a file another service produced. The download streams straight into storage and never passes through your function, so it is not bound by fetch's 1MB response cap: use it for images, audio, video and PDFs up to 100MB. Don't fetch the file and try to upload the bytes yourself; that hits the cap and there is no byte-upload API in functions.
- Declare the host in
fetch_domains. importUrl follows the same egress rules asfetch: the URL's host must be listed, every redirect hop is re-checked, private/internal addresses are refused, and each call counts againstmax_fetches. If the provider redirects to a separate file host (common for signed download links), list that host too. - opts (all optional):
filename(default: the response'sContent-Dispositionname, else the URL's last path segment, given an extension from the content type when it has none),path(folder, defaultuploads, same asuploads/init),public(defaultfalse, a private file served through the durable URL),table+record_id(link the file to a record, like uploads do),content_type(override; default is the response's type, or the filename's when the server only saysapplication/octet-stream). Unknown option names throw. - Returns the same object as an upload (
Gipity.upload/uploads/complete):{ guid, name, size, content_type, url, is_public, table, record_id, width, height, variants, warnings? }. Images get a thumbnail and dimensions, PDFs get extracted text, exactly as if a user had uploaded them. Store theguidand resolve a URL later withstorage.fileUrl(guid)(or storeurl, which is durable). - Throws with a clear message when the remote responds non-2xx (
<host> responded 404 Not Found), the file is over 100MB, it would exceed the app owner's storage quota, the host isn't declared or is blocked, or the download takes longer than 25s (or longer than the function has left to run; plan on the import using most of a request's time budget for a big file). Nothing is stored when it throws. - The file is stored and billed against the app owner, like every app upload.
The canonical recipe: an external job (an image or video generator, a render farm, a transcoder) calls your webhook when it finishes, and the webhook saves the result and records it:
# gipity.yaml (functions)
- name: render-done
auth: public # the provider calls it, no Gipity sign-in
public_write: true # a signed webhook writing data is intended
tables: [renders]
fetch_domains: [cdn.render-provider.com]
// functions/render-done.js
export default async function (ctx, { db, secrets, crypto, storage }) {
// Verify the caller is really the provider (its signature scheme; see
// "Verifying webhook signatures" in the app-development-reference skill).
const expected = crypto.hmac('sha256', await secrets.get('RENDER_WEBHOOK_SECRET'), ctx.rawBody);
if (!crypto.timingSafeEqual(expected, ctx.headers['x-render-signature'] || '')) {
return { error: 'bad signature' };
}
const { job_id, status, output_url } = ctx.body;
if (status !== 'succeeded') {
await db.update('renders', { job_id }, { status });
return { ok: true };
}
const existing = await db.findOne('renders', { job_id });
if (existing?.file_guid) return { ok: true, file_guid: existing.file_guid }; // a retried delivery
const file = await storage.importUrl(output_url, {
path: 'renders',
filename: `${job_id}.png`,
table: 'renders',
record_id: job_id,
});
await db.update('renders', { job_id }, { status: 'done', file_guid: file.guid });
return { ok: true, file_guid: file.guid };
}
The browser then shows it with Gipity.fileUrl(file_guid) (or a function returns await storage.fileUrl(file_guid)). Webhook providers retry on errors, so the handler is idempotent: it skips the import when the row already has a file_guid.
Limits
storage.importUrl: 100MB per file, 25s per import (or the function's remaining time, whichever is less), counts againstmax_fetches- Upload size, rate and variant limits: app-files
Related Skills
- app-files - uploading from the browser:
Gipity.upload, folder drops, the upload endpoints - app-files-display - durable URLs, share links, thumbnails and other variants
- app-development - writing functions and
gipity.yaml(fetch_domains,auth) - app-development-reference - verifying webhook signatures