# App API - The `storage` Helper in Functions

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](app-files.md); showing files (durable URLs, share links, variants) is in [app-files-display](app-files-display.md).

## The helper

Every deployed function gets a `storage` helper in its `services` argument (always available, like `db` - no `services:` declaration needed):

```js
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](app-files-display.md#serving-private-files-to-anonymous-viewers-share-links).

## 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).

```js
// 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](app-testing.md#testing-file-serving-with-ctxupload)). A signed-out upload has `uploadedBy: null` and fails this check, so show the upload UI only after sign-in (see [app-auth](app-auth.md)).

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

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

```js
// 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](app-files-display.md#variants)); 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](jobs.md)). 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](app-vector-search.md)).

## 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 as `fetch`: the URL's host must be listed, every redirect hop is re-checked, private/internal addresses are refused, and each call counts against `max_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's `Content-Disposition` name, else the URL's last path segment, given an extension from the content type when it has none), `path` (folder, default `uploads`, same as `uploads/init`), `public` (default `false`, 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 says `application/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 the `guid`** and resolve a URL later with `storage.fileUrl(guid)` (or store `url`, 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:

```yaml
# 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]
```

```js
// 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 against `max_fetches`
- Upload size, rate and variant limits: [app-files](app-files.md#limits)

## Related Skills

- [app-files](app-files.md) - uploading from the browser: `Gipity.upload`, folder drops, the upload endpoints
- [app-files-display](app-files-display.md) - durable URLs, share links, thumbnails and other variants
- [app-development](app-development.md) - writing functions and `gipity.yaml` (`fetch_domains`, `auth`)
- [app-development-reference](app-development-reference.md#verifying-webhook-signatures-via-crypto) - verifying webhook signatures

