411 lines
11 KiB
TypeScript
411 lines
11 KiB
TypeScript
import { randomUUID } from 'crypto'
|
|
import { FastifyRequest } from 'fastify'
|
|
|
|
import { ERRORS } from '@internal/errors'
|
|
import { FileUploadedSuccess, FileUploadStarted } from '@internal/monitoring/metrics'
|
|
|
|
import { ObjectMetadata, StorageBackendAdapter } from './backend'
|
|
import { getFileSizeLimit, isEmptyFolder } from './limits'
|
|
import { Database } from './database'
|
|
import { ObjectAdminDelete, ObjectCreatedPostEvent, ObjectCreatedPutEvent } from './events'
|
|
import { getConfig } from '../config'
|
|
import { logger, logSchema } from '@internal/monitoring'
|
|
import { Readable } from 'stream'
|
|
|
|
const { storageS3Bucket, uploadFileSizeLimitStandard } = getConfig()
|
|
|
|
interface FileUpload {
|
|
body: Readable
|
|
mimeType: string
|
|
cacheControl: string
|
|
isTruncated: () => boolean
|
|
userMetadata?: Record<string, any>
|
|
}
|
|
|
|
export interface UploadRequest {
|
|
bucketId: string
|
|
objectName: string
|
|
file: FileUpload
|
|
owner?: string
|
|
isUpsert?: boolean
|
|
uploadType?: 'standard' | 's3' | 'resumable'
|
|
signal?: AbortSignal
|
|
}
|
|
|
|
const MAX_CUSTOM_METADATA_SIZE = 1024 * 1024
|
|
|
|
/**
|
|
* Uploader
|
|
* Handles the upload of a multi-part request or binary body
|
|
*/
|
|
export class Uploader {
|
|
constructor(private readonly backend: StorageBackendAdapter, private readonly db: Database) {}
|
|
|
|
async canUpload(options: Pick<UploadRequest, 'bucketId' | 'objectName' | 'isUpsert' | 'owner'>) {
|
|
const shouldCreateObject = !options.isUpsert
|
|
|
|
if (shouldCreateObject) {
|
|
await this.db.testPermission((db) => {
|
|
return db.createObject({
|
|
bucket_id: options.bucketId,
|
|
name: options.objectName,
|
|
version: '1',
|
|
owner: options.owner,
|
|
})
|
|
})
|
|
} else {
|
|
await this.db.testPermission((db) => {
|
|
return db.upsertObject({
|
|
bucket_id: options.bucketId,
|
|
name: options.objectName,
|
|
version: '1',
|
|
owner: options.owner,
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the upload version for the incoming file.
|
|
* We check RLS policies before proceeding
|
|
* @param options
|
|
*/
|
|
async prepareUpload(options: Omit<UploadRequest, 'file'>) {
|
|
await this.canUpload(options)
|
|
FileUploadStarted.inc({
|
|
is_multipart: Boolean(options.uploadType).toString(),
|
|
})
|
|
|
|
return randomUUID()
|
|
}
|
|
|
|
/**
|
|
* Extracts file information from the request and upload the buffer
|
|
* to the remote storage
|
|
* @param request
|
|
* @param options
|
|
*/
|
|
async upload(request: UploadRequest) {
|
|
const version = await this.prepareUpload(request)
|
|
|
|
try {
|
|
const file = request.file
|
|
|
|
const path = `${request.bucketId}/${request.objectName}`
|
|
const s3Key = `${this.db.tenantId}/${path}`
|
|
|
|
const objectMetadata = await this.backend.uploadObject(
|
|
storageS3Bucket,
|
|
s3Key,
|
|
version,
|
|
file.body,
|
|
file.mimeType,
|
|
file.cacheControl,
|
|
request.signal
|
|
)
|
|
|
|
if (file.isTruncated()) {
|
|
throw ERRORS.EntityTooLarge()
|
|
}
|
|
|
|
return this.completeUpload({
|
|
...request,
|
|
version,
|
|
objectMetadata: objectMetadata,
|
|
userMetadata: { ...file.userMetadata },
|
|
})
|
|
} catch (e) {
|
|
await ObjectAdminDelete.send({
|
|
name: request.objectName,
|
|
bucketId: request.bucketId,
|
|
tenant: this.db.tenant(),
|
|
version: version,
|
|
reqId: this.db.reqId,
|
|
})
|
|
throw e
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Completes the upload process by updating the object metadata
|
|
* @param version
|
|
* @param bucketId
|
|
* @param objectName
|
|
* @param owner
|
|
* @param objectMetadata
|
|
* @param uploadType
|
|
* @param isUpsert
|
|
* @param userMetadata
|
|
*/
|
|
async completeUpload({
|
|
version,
|
|
bucketId,
|
|
objectName,
|
|
owner,
|
|
objectMetadata,
|
|
uploadType,
|
|
isUpsert,
|
|
userMetadata,
|
|
}: Omit<UploadRequest, 'file'> & {
|
|
objectMetadata: ObjectMetadata
|
|
version: string
|
|
emitEvent?: boolean
|
|
uploadType?: 'standard' | 's3' | 'resumable'
|
|
userMetadata?: Record<string, unknown>
|
|
}) {
|
|
try {
|
|
return await this.db.asSuperUser().withTransaction(async (db) => {
|
|
await db.waitObjectLock(bucketId, objectName, undefined, {
|
|
timeout: 5000,
|
|
})
|
|
|
|
const currentObj = await db.findObject(bucketId, objectName, 'id, version, metadata', {
|
|
forUpdate: true,
|
|
dontErrorOnEmpty: true,
|
|
})
|
|
|
|
const isNew = !Boolean(currentObj)
|
|
|
|
// update object
|
|
const newObject = await db.upsertObject({
|
|
bucket_id: bucketId,
|
|
name: objectName,
|
|
metadata: objectMetadata,
|
|
user_metadata: userMetadata,
|
|
version,
|
|
owner,
|
|
})
|
|
|
|
const events: Promise<unknown>[] = []
|
|
|
|
// schedule the deletion of the previous file
|
|
if (currentObj && currentObj.version !== version) {
|
|
events.push(
|
|
ObjectAdminDelete.send({
|
|
name: objectName,
|
|
bucketId: bucketId,
|
|
tenant: this.db.tenant(),
|
|
version: currentObj.version,
|
|
reqId: this.db.reqId,
|
|
})
|
|
)
|
|
}
|
|
|
|
const event = isUpsert && !isNew ? ObjectCreatedPutEvent : ObjectCreatedPostEvent
|
|
|
|
events.push(
|
|
event
|
|
.sendWebhook({
|
|
tenant: this.db.tenant(),
|
|
name: objectName,
|
|
version: version,
|
|
bucketId: bucketId,
|
|
metadata: objectMetadata,
|
|
reqId: this.db.reqId,
|
|
uploadType,
|
|
})
|
|
.catch((e) => {
|
|
logSchema.error(logger, 'Failed to send webhook', {
|
|
type: 'event',
|
|
error: e,
|
|
project: this.db.tenantId,
|
|
metadata: JSON.stringify({
|
|
name: objectName,
|
|
bucketId: bucketId,
|
|
metadata: objectMetadata,
|
|
reqId: this.db.reqId,
|
|
uploadType,
|
|
}),
|
|
})
|
|
})
|
|
)
|
|
|
|
await Promise.all(events)
|
|
|
|
FileUploadedSuccess.inc({
|
|
is_multipart: uploadType === 'resumable' ? 1 : 0,
|
|
is_resumable: uploadType === 'resumable' ? 1 : 0,
|
|
is_standard: uploadType === 'standard' ? 1 : 0,
|
|
is_s3: uploadType === 's3' ? 1 : 0,
|
|
})
|
|
|
|
return { obj: newObject, isNew, metadata: objectMetadata }
|
|
})
|
|
} catch (e) {
|
|
await ObjectAdminDelete.send({
|
|
name: objectName,
|
|
bucketId: bucketId,
|
|
tenant: this.db.tenant(),
|
|
version,
|
|
reqId: this.db.reqId,
|
|
})
|
|
throw e
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates the mime type of the incoming file
|
|
* @param mimeType
|
|
* @param allowedMimeTypes
|
|
*/
|
|
export function validateMimeType(mimeType: string, allowedMimeTypes: string[]) {
|
|
const requestedMime = mimeType.split('/')
|
|
|
|
if (requestedMime.length < 2) {
|
|
throw ERRORS.InvalidMimeType(mimeType)
|
|
}
|
|
|
|
const [type, ext] = requestedMime
|
|
|
|
for (const allowedMimeType of allowedMimeTypes) {
|
|
const allowedMime = allowedMimeType.split('/')
|
|
|
|
if (requestedMime.length < 2) {
|
|
continue
|
|
}
|
|
|
|
const [allowedType, allowedExtension] = allowedMime
|
|
|
|
if (allowedType === type && allowedExtension === '*') {
|
|
return true
|
|
}
|
|
|
|
if (allowedType === type && allowedExtension === ext) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
throw ERRORS.InvalidMimeType(mimeType)
|
|
}
|
|
|
|
/**
|
|
* Extracts the file information from the request
|
|
* @param request
|
|
* @param options
|
|
*/
|
|
export async function fileUploadFromRequest(
|
|
request: FastifyRequest,
|
|
options: {
|
|
fileSizeLimit?: number | null
|
|
allowedMimeTypes?: string[]
|
|
objectName: string
|
|
}
|
|
): Promise<FileUpload> {
|
|
const contentType = request.headers['content-type']
|
|
|
|
let body: Readable
|
|
let userMetadata: Record<string, any> | undefined
|
|
let mimeType: string
|
|
let isTruncated: () => boolean
|
|
let maxFileSize = 0
|
|
|
|
// When is an empty folder we restrict it to 0 bytes
|
|
if (!isEmptyFolder(options.objectName)) {
|
|
maxFileSize = await getStandardMaxFileSizeLimit(request.tenantId, options?.fileSizeLimit)
|
|
}
|
|
|
|
let cacheControl: string
|
|
if (contentType?.startsWith('multipart/form-data')) {
|
|
try {
|
|
const formData = await request.file({ limits: { fileSize: maxFileSize } })
|
|
|
|
if (!formData) {
|
|
throw ERRORS.NoContentProvided()
|
|
}
|
|
|
|
// https://github.com/fastify/fastify-multipart/issues/162
|
|
/* @ts-expect-error: https://github.com/aws/aws-sdk-js-v3/issues/2085 */
|
|
const cacheTime = formData.fields.cacheControl?.value
|
|
|
|
body = formData.file
|
|
/* @ts-expect-error: https://github.com/aws/aws-sdk-js-v3/issues/2085 */
|
|
const customMd = formData.fields.metadata?.value ?? formData.fields.userMetadata?.value
|
|
/* @ts-expect-error: https://github.com/aws/aws-sdk-js-v3/issues/2085 */
|
|
mimeType = formData.fields.contentType?.value || formData.mimetype
|
|
cacheControl = cacheTime ? `max-age=${cacheTime}` : 'no-cache'
|
|
isTruncated = () => formData.file.truncated
|
|
|
|
if (
|
|
options.allowedMimeTypes &&
|
|
options.allowedMimeTypes.length > 0 &&
|
|
!isEmptyFolder(options.objectName)
|
|
) {
|
|
validateMimeType(mimeType, options.allowedMimeTypes)
|
|
}
|
|
|
|
if (typeof customMd === 'string') {
|
|
if (Buffer.byteLength(customMd, 'utf8') > MAX_CUSTOM_METADATA_SIZE) {
|
|
throw ERRORS.EntityTooLarge(undefined, 'user_metadata')
|
|
}
|
|
|
|
try {
|
|
userMetadata = JSON.parse(customMd)
|
|
} catch (e) {
|
|
// no-op
|
|
}
|
|
}
|
|
} catch (e) {
|
|
throw ERRORS.NoContentProvided(e as Error)
|
|
}
|
|
} else {
|
|
// just assume it's a binary file
|
|
body = request.raw
|
|
mimeType = request.headers['content-type'] || 'application/octet-stream'
|
|
cacheControl = request.headers['cache-control'] ?? 'no-cache'
|
|
|
|
if (
|
|
options.allowedMimeTypes &&
|
|
options.allowedMimeTypes.length > 0 &&
|
|
!isEmptyFolder(options.objectName)
|
|
) {
|
|
validateMimeType(mimeType, options.allowedMimeTypes)
|
|
}
|
|
|
|
const customMd = request.headers['x-metadata']
|
|
|
|
if (typeof customMd === 'string') {
|
|
userMetadata = parseUserMetadata(customMd)
|
|
}
|
|
isTruncated = () => {
|
|
// @todo more secure to get this from the stream or from s3 in the next step
|
|
return Number(request.headers['content-length']) > maxFileSize
|
|
}
|
|
}
|
|
|
|
return {
|
|
body,
|
|
mimeType,
|
|
cacheControl,
|
|
isTruncated,
|
|
userMetadata,
|
|
}
|
|
}
|
|
|
|
export function parseUserMetadata(metadata: string) {
|
|
try {
|
|
const json = Buffer.from(metadata, 'base64').toString('utf8')
|
|
return JSON.parse(json) as Record<string, string>
|
|
} catch (e) {
|
|
// no-op
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
export async function getStandardMaxFileSizeLimit(
|
|
tenantId: string,
|
|
bucketSizeLimit?: number | null
|
|
) {
|
|
let globalFileSizeLimit = await getFileSizeLimit(tenantId)
|
|
|
|
if (typeof bucketSizeLimit === 'number') {
|
|
globalFileSizeLimit = Math.min(bucketSizeLimit, globalFileSizeLimit)
|
|
}
|
|
|
|
if (uploadFileSizeLimitStandard && uploadFileSizeLimitStandard > 0) {
|
|
globalFileSizeLimit = Math.min(uploadFileSizeLimitStandard, globalFileSizeLimit)
|
|
}
|
|
|
|
return globalFileSizeLimit
|
|
}
|