# Attachments

> Files linked to records in a tenant, uploaded through signed URLs to a private bucket and served only after a malware scan.

Source: https://bettersupabase.com/docs/blocks/attachments

The `attachments` block links files to records in a tenant. Each file gets
a row in `attachments` and an object in a private Storage bucket at
`{organization_id}/attachments/{id}`. The bucket's policies accept an
upload only when a pending record of the caller expects it, and serve a
file only after your scanner marks it clean.

```bash
better-supabase sql add attachments   # adds tenant and access as well
```

| Column                       | Holds                                                               |
| ---------------------------- | ------------------------------------------------------------------- |
| `subject_type`, `subject_id` | The record the file belongs to, or both null                        |
| `bucket`, `object_path`      | Where the object lives; the path is generated from the id           |
| `name`, `mime_type`, `size`  | The file name, and the type and size Storage stored                 |
| `status`, `scan_detail`      | `pending`, `clean`, `infected` or `failed`, and what the scan found |
| `uploaded_by`, `uploaded_at` | The uploader, and when `confirm` saw the object                     |

| Permission           | Lets a member                                 | Default roles             |
| -------------------- | --------------------------------------------- | ------------------------- |
| `attachments.read`   | list attachments and download clean files     | `member`, `admin`         |
| `attachments.upload` | upload files                                  | `member`, `admin`         |
| `attachments.manage` | read any file and delete other members' files | `admin` (`attachments.*`) |

The uploader can always read and delete their own file.

## Options [#options]

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      attachments: {
        options: {
          bucket: "attachments",
          maxSize: 25 * 1024 * 1024,
          allowedMimeTypes: ["image/*", "application/pdf"],
          requireScan: true,
        },
      },
    },
  },
});
```

The module creates the bucket as private with `maxSize` (default 50 MiB) as
its file size limit and `allowedMimeTypes` as its allowed types; an existing
bucket keeps its settings. The table checks the same limits. With
`requireScan: false`, `confirm` marks a file clean right away, and files
nobody scanned are served too (an `infected` one never is).

### Subjects and buckets [#subjects-and-buckets]

`subjects` maps each subject type to its table, as for
[comments](/docs/blocks/comments#subjects): uploading and reading a file then
also need the subject row to be readable through the subject table's own
policies (and its `permission`), and an unlisted type is refused. A subject
can keep its files in its own bucket with its own MIME types, so an app with a
bucket per feature keeps them:

```ts
options: {
  bucket: "attachments",
  path: "{organization_id}/{subject_type}/{subject_id}/{id}",
  subjects: {
    chat_message: { table: "chat_messages", bucket: "chat", allowedMimeTypes: ["image/*", "application/pdf"] },
    expense: { table: "expenses", bucket: "receipts", allowedMimeTypes: ["image/*"], cascade: true },
  },
},
```

The module creates every listed bucket that doesn't exist yet and adds its
storage policies for each, next to any policies the bucket already has. With
subjects, reading an object also needs its subject: the storage read policy
checks that the caller sees the attachment row through its own read policy
(`attachment_object_visible`), so a member who can't see the subject, or who
isn't in the tenant, can't download its files, uploader and
`attachments.manage` included.
`cascade: true` deletes a subject's attachment rows with the subject row;
remove the objects with `attachments.remove` or a storage cleanup job.

A subject without a tenant, such as a user's own notes, takes `tenant: false`:
its files have no `organization_id` (pass `organizationId: null` to `upload`),
their path starts with `-`, and the subject table's own policies alone decide
who reads and uploads them, without tenant permissions. Other files still need
a tenant.

```ts
subjects: {
  note: { table: "notes", tenant: false },
},
```

`path` sets the object path, from `{organization_id}` (required first),
`{id}` (required), `{subject_type}` and `{subject_id}` (a file without a
subject gets `-`). It defaults to `{organization_id}/attachments/{id}`.
Changing it later rewrites the column on Postgres 17; on older versions,
recreate the column in a migration.

## Upload and download [#upload-and-download]

Uploading takes three steps: create the record and a signed upload URL on
the server, upload the file from the browser, then confirm.

```ts title="app/projects/[id]/actions.ts"
"use server";

import {
  createAttachments,
  rpcTransport,
} from "better-supabase/blocks/attachments";

export async function startUpload(projectId: string, file: FileMeta) {
  const supabase = await createServerClient();
  const attachments = createAttachments({
    transport: rpcTransport(supabase),
    storage: supabase.storage,
  });
  return attachments
    .upload({
      organizationId,
      subjectType: "project",
      subjectId: projectId,
      name: file.name,
      mimeType: file.type,
      size: file.size,
    })
    .orThrow();
}
```

The browser uploads with the token,
`supabase.storage.from(bucket).uploadToSignedUrl(path, token, file)`, or a
`PUT` to `signedUrl`. Then `attachments.confirm(id)` checks that the object
exists, takes its size and type from Storage and writes
`attachment.uploaded` to the outbox. `confirm` returns
`not_found` with the hint `ATTACHMENT_NOT_UPLOADED` until the object exists.

`upload` takes `metadata`, a JSON object kept with the record in the
`metadata` column (a caption, where the file came from, a page count), and
every attachment returns it as `metadata` (`{}` without any).

`download(id)` returns a signed URL (300 seconds by default, `downloadTtl`
changes it; `{ as: "plan.pdf" }` sets the download name). Until the scan
passes it returns `invalid_request` with the hint `ATTACHMENT_NOT_SCANNED`.
`list(organizationId, { type, id })` lists a record's files oldest first,
and `remove(id)` deletes the object and then the row.

Files the server makes or fetches (an export, a generated PDF, an import
from a URL) go through `put(attachment, file)`, which creates the record,
uploads the bytes with the storage client and confirms it in one call.
`read(id)` returns the bytes as a `Blob` for server-side processing, behind
the same scan gate as `download` (`ATTACHMENT_NOT_SCANNED` until the scan
passes):

```ts
const report = await attachments
  .put(
    {
      organizationId,
      subjectType: "project",
      subjectId,
      name: "report.pdf",
      mimeType: "application/pdf",
      size: pdf.byteLength,
    },
    pdf,
  )
  .orThrow();

const { file } = await attachments.read(attachmentId).orThrow();
const text = await extractText(await file.arrayBuffer());
```

Pass the caller's Supabase client so the table and bucket policies apply;
`createAttachments` never needs the service role. A service-role client works
too, for jobs that act for no user.

## Scanning [#scanning]

`createAttachmentScanner` runs your scanner (ClamAV, a scanning API) on each
uploaded file with a service-role client, and records the verdict with
`set_attachment_status`, which only the service role can call.

```ts title="app/api/cron/attachments/route.ts"
import {
  createAttachmentScanner,
  sqlTransport,
} from "better-supabase/blocks/attachments";

const scanner = createAttachmentScanner({
  transport: sqlTransport(postgres.admin),
  storage: supabaseAdmin.storage,
  scan: async (file) => {
    const result = await clamav.scan(await file.arrayBuffer());
    return result.infected
      ? { status: "infected", detail: result.signature }
      : { status: "clean" };
  },
});

await outbox.relay("attachment-scans", scanner.sink());
```

`scanner.sink()` scans the file of each `attachment.uploaded` event, and
`scanner.job` is a [jobs](/docs/blocks/jobs) handler for an
`{ attachmentId }` payload when you prefer a queue. A file that is already
clean or infected is not scanned again, so replays are safe. When `scan`
throws, the file is marked `failed` with the error as `scan_detail`, the
result is an error with the hint `ATTACHMENT_SCAN_FAILED`, and the next
attempt scans it again. An event or job for an attachment that was deleted
before its scan is done: the sink skips it and the job completes, so one
deleted file never blocks the events behind it in the relay. Other errors,
such as a failed download or a throwing `scan`, still throw so the relay or
the queue retries.

### Any bucket [#any-bucket]

The scan gate also works without the attachments table, for a file drive,
avatars or generated exports. `scanned_objects` holds one row per object,
`object_clean(bucket, path)` answers in any storage policy, and
`createObjectScanner` records verdicts with `set_object_scan` (service role):

```sql
create policy "drive files are clean" on storage.objects for select to authenticated
  using (bucket_id = 'files' and better_supabase.object_clean(bucket_id, name) and ...);
```

```ts
const objects = createObjectScanner({
  transport: sqlTransport(postgres.admin),
  storage: supabaseAdmin.storage,
  scan: (file, { bucket, path }) => scanWithClamav(file),
});
await outbox.relay("object-scans", objects.sink());
```

With `scanBuckets: ["files"]`, a trigger on `storage.objects` gives each new
or replaced object in those buckets a `pending` row and writes an
`object.uploaded` event (`{ bucket, path }`), which `objects.sink()` scans;
`objects.job` takes the same payload from a queue. An object deleted from
storage before its scan (`NoSuchKey`) is skipped the same way; a missing
bucket or any other error still throws. Scanning an attachment also records
its object in `scanned_objects`, so both gates agree.

## Events [#events]

With the outbox installed, the module writes these events with the tenant as
the partition key and `attachments/<id>` as the subject:

| Event                 | When                                                   | Data                                                                         |
| --------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------- |
| `attachment.uploaded` | `confirm` saw the object                               | `attachmentId`, `subjectType`, `subjectId`, `uploadedBy`, `mimeType`, `size` |
| `attachment.scanned`  | a scan result was recorded                             | `attachmentId`, `status`, `uploadedBy`                                       |
| `object.uploaded`     | an object landed in a `scanBuckets` bucket (no tenant) | `bucket`, `path`                                                             |