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 inattachFile. 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 keyextension: lowercase extension without the dot (e.g.png),undefinedif unknownfileName,contentTypeserviceName,modelName,attachmentName
const uplo = createUplo({
attachments: {
user: {
avatar: {
storagePath: ({ key, extension }) => `avatars/${key}${extension ? `.${extension}` : ''}`,
},
},
},
})
// => avatars/3f9a...c1.pngIt 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 trustedClient 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 deletedpurge() 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 aDate. It must be at leastsignedIdExpiresIn, 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 returnhasMore: 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:
BlobNotUploadedErrorwhen the file does not exist (the upload failed or never happened).BlobValidationErrorwhen 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.