Uplo

Attachments

Getting started

Add your first attachments to Uplo. For example, we will add avatar for User and multiple images for Post.

const uplo = createUplo({
  // ... other Uplo config
  attachments: {
    user: {
      avatar: true, // pass true to use default options
    },
    post: {
      images: { multiple: true },
    },
  },
})

Options

  • multiple = false (optional): Specify for multiple attachment support on a single record.
  • serviceName (optional): You can provide a custom service.
  • directUpload = true (optional): You can disable direct uploads for specific attachments if needed.
  • storagePath (optional): Function that returns the file path in the bucket. See Storage path.
  • validate (optional): Allowed content types and file size. See Validation.

Validation

const uplo = createUplo({
  attachments: {
    user: {
      avatar: {
        validate: {
          contentType: ['image/png', 'image/jpeg', 'image/webp'],
          size: { max: 5 * 1024 * 1024 }, // 5 MB
        },
      },
    },
  },
})
  • contentType: allowed content type, as a string, RegExp, or an array of them. Strings must match exactly.
  • size.min, size.max: file size limits in bytes.
  • custom: your own check, see Custom validation.

Validation runs when a direct upload is created and in attachFile. Failed validation throws BlobValidationError (422 from the upload routes).

For direct uploads the client declares content type and size, and the upload URL is signed with them, so the storage refuses a different file:

  • S3 and R2 sign both content type and size.
  • GCS signs content type, and size only with enforceContentLength.

attachSignedFile also checks the stored file before attaching it, see Attach signed file.

Custom validation

custom runs after the contentType and size checks, in the same places. Return an error message to refuse the file, or nothing to accept it. It can be async.

const uplo = createUplo({
  attachments: {
    post: {
      images: {
        multiple: true,
        validate: {
          contentType: /^image\/(png|jpeg|webp)$/,
          custom: async ({ fileName, size, metadata, recordId }) => {
            if (fileName.length > 255) {
              return 'File name is too long'
            }
            if (recordId && (await countPostImages(recordId)) >= 10) {
              return 'A post can have up to 10 images'
            }
          },
        },
      },
    },
  },
})

It receives fileName, contentType, size, checksum, modelName and attachmentName, plus:

  • metadata: client metadata, only for direct uploads.
  • recordId: only in attachFile. Direct uploads are created before the record is known, so check record-based rules again when you attach the Signed ID.

A returned message throws BlobValidationError (422 from the upload routes). Errors you throw are passed on as they are.

Always limit content types

Without contentType, a client can upload any type, including text/html or image/svg+xml. A public bucket serves them as-is, so a browser runs their scripts on the bucket domain, or on your domain if the bucket is behind it (e.g. files.example.com). Set contentType for every attachment, and do not allow HTML or SVG on public buckets.

Anchor regular expressions. /image\// also matches text/html; x=image/, use /^image\/(png|jpeg|webp)$/ instead.

Storage path

By default every file is stored in the bucket root with a random key. Pass storagePath to choose where files go. It receives:

  • key: random generated key
  • extension: lowercase extension without the dot (e.g. png), undefined if unknown
  • fileName, contentType
  • serviceName, modelName, attachmentName
const uplo = createUplo({
  attachments: {
    user: {
      avatar: {
        storagePath: ({ key, extension }) => `avatars/${key}${extension ? `.${extension}` : ''}`,
      },
    },
  },
})
// => avatars/3f9a...c1.png

It can also be async. You can set a default storagePath on a service too (e.g. createS3Service({ storagePath })). It is used for every attachment stored in that service that has no storagePath of its own.

The returned path is saved as the blob key, so changing storagePath later only affects new uploads.

Path segments cannot be empty, . or .., otherwise it throws. Do not use fileName as-is: it comes from the client and can contain /, .., ? or #. Use key and extension instead, or clean fileName first.

Metadata

blob.metadata holds fields written on the server, e.g. width, height and blurhash from analyzers, and analyzed: true once they ran.

Metadata sent by the client with a direct upload (see React or React Native) is stored apart, as blob.metadata.client, so it cannot override server fields:

const attachment = await uplo.attachments.user(123).avatar.findOne()

attachment.blob.metadata.width // from the image analyzer
attachment.blob.metadata.client?.caption // from the client, not trusted

Client metadata must be a flat object of up to 20 keys. Keys start with a letter and have up to 64 letters, digits, _ or -. Values are strings (up to 1000 characters), numbers or booleans. Other metadata is refused with BlobValidationError (422 from the upload routes).

Find attachment

Find an attachment by specifying User ID.

Single

const attachment = await uplo.attachments.user(123).avatar.findOne()

console.log(attachment.blob.url())

Multiple

const attachments = await uplo.attachments.post(456).images.findMany()

Detach attachment

Detach an attachment from a record.

Single

const attachment = await uplo.attachments.user(123).avatar.detach()

Multiple

Detach one attachment.

const attachments = await uplo.attachments.post(456).images.detach(attachmentId)

Only an attachment of this record and attachment name is detached. If attachmentId belongs to another record, it throws AttachmentNotFoundError.

Detach all attachments.

const attachments = await uplo.attachments.post(456).images.detachMany()

Detach keeps the blob and its file. Pass purge: true to delete them too:

await uplo.attachments.user(123).avatar.detach({ purge: true })
await uplo.attachments.post(456).images.detach(attachmentId, { purge: true })
await uplo.attachments.post(456).images.detachMany({ purge: true })

Delete files

Files stay in storage until they are purged. Purge deletes the file first and the blob row last, so a failed purge can always be run again.

Purge a blob

const blob = await uplo.$findBlob(blobId)
await blob?.purge() // true when deleted

purge() returns false when the blob is still attached or already deleted, so it is safe to run twice, e.g. from a job queue. When the storage fails, it throws and keeps the blob row.

Replacing a single attachment detaches the old blob. Purge it after the new one is attached:

const avatar = uplo.attachments.user(123).avatar

const old = await avatar.findOne()
const attachment = await avatar.attachSignedFile(signedId)

if (old && old.blob.id !== attachment.blob.id) {
  await old.blob.purge()
}

Purge unattached blobs

Direct uploads that were never attached, and blobs that were detached or replaced, stay in storage. Delete them from a cron job:

const result = await uplo.purgeUnattached({ olderThan: '1d' })
// { blobs: [...purged], errors: [{ blob, error }], hasMore: false }
  • olderThan = '1d': only blobs created before this. Seconds, a string like '12h' or '7d', or a Date. It must be at least signedIdExpiresIn, so uploads that can still be attached are never deleted.
  • batchSize = 100: blobs loaded per query. Blobs are deleted one by one.
  • maxDuration: stop after this time and return hasMore: true, e.g. '50s' on serverless.
  • dryRun = false: only return the blobs that would be deleted.

Failed blobs are returned in errors and tried again on the next run.

Purge deletes every blob that has no attachment. If your app keeps blob IDs in its own tables, attach those files instead, or they will be deleted. Run with dryRun: true first.

Purge needs adapter methods findUnattachedBlobs and deleteUnattachedBlob. The Prisma and Drizzle adapters have them. Custom adapters without them throw PurgeNotSupportedError.

Attach file

Attach a file from a file path.

import { blobFileInput } from '@uplo/node'

const fileInput = await blobFileInput({
  path: '/home/images/image.png',
  // contentType: 'image/png', // Optional
  // fileName: 'image.png' // Optional
})

const attachment = await uplo.attachments.user(123).avatar.attachFile(fileInput)

Attach a file with string.

import { blobStringInput } from '@uplo/node'

const fileInput = await blobStringInput({
  content: 'Hello',
  contentType: 'text/plain',
  fileName: 'welcome.txt',
})

const attachment = await uplo.attachments.user(123).avatar.attachFile(fileInput)

Attach a file with buffer.

import { blobBufferInput } from '@uplo/node'

const fileInput = await blobBufferInput({
  buffer,
  contentType: 'text/plain',
  fileName: 'welcome.txt',
})

const attachment = await uplo.attachments.user(123).avatar.attachFile(fileInput)

Attach signed file

If you use direct uploads, you get a Signed ID. You need to manually attach a Signed ID to your records. How to get a Signed ID, please view Client documentation.

const signedId = req.params['signedId']
const attachment = await uplo.attachments.user(123).avatar.attachSignedFile(signedId)

A Signed ID can only be attached to the attachment it was created for. For example, a Signed ID from a user.avatar direct upload cannot be attached to user.document. Attachments with directUpload: false refuse Signed IDs.

A Signed ID is attached once. Attaching it again to the same record and attachment returns the existing attachment (e.g. when a form is saved twice). Attaching it to another record throws BlobAlreadyAttachedError.

Before attaching, Uplo checks the file in storage, because the client uploads it straight there:

  • BlobNotUploadedError when the file does not exist (the upload failed or never happened).
  • BlobValidationError when its size or content type differs from what was declared.

The S3, R2 and GCS services support this check. A custom service supports it by implementing getObjectInfo({ key }), otherwise the check is skipped.

On this page