Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions migrations/tenant/0056-s3-multipart-uploads-metadata.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE storage.s3_multipart_uploads ADD COLUMN IF NOT EXISTS metadata jsonb NULL;
20 changes: 20 additions & 0 deletions src/http/routes/object/getSignedUploadURL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createDefaultSchema } from '../../routes-helper'
import { AuthenticatedRequest } from '../../types'
import { getConfig } from '../../../config'
import { ROUTE_OPERATIONS } from '../operations'
import { parseUserMetadata } from '../../../storage/uploader'

const { uploadSignedUrlExpirationTime } = getConfig()

Expand All @@ -20,6 +21,8 @@ const getSignedUploadURLHeadersSchema = {
type: 'object',
properties: {
'x-upsert': { type: 'string' },
'content-type': { type: 'string' },
'content-length': { type: 'string' },
authorization: { type: 'string' },
},
required: ['authorization'],
Expand Down Expand Up @@ -69,10 +72,27 @@ export default async function routes(fastify: FastifyInstance) {

const urlPath = `${bucketName}/${objectName}`

let userMetadata: Record<string, unknown> | undefined

const customMd = request.headers['x-metadata']

if (typeof customMd === 'string') {
userMetadata = parseUserMetadata(customMd)
}

const contentType = request.headers['content-type']
const contentLengthHeader = request.headers['content-length']
const contentLength = contentLengthHeader ? Number(contentLengthHeader) : undefined

const signedUpload = await request.storage
.from(bucketName)
.signUploadObjectUrl(objectName, urlPath as string, uploadSignedUrlExpirationTime, owner, {
upsert: request.headers['x-upsert'] === 'true',
userMetadata: userMetadata,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we don't actually support userMetadata on upload signed urls (which we should) so I'm glad you found this.

I believe we need to store the userMetadata in the returned JWT payload; otherwise, this information will be lost during the signedUpload request. We need to be careful, though, if we decide to store the metadata in the JWT, because it can be abused with large payloads. We can for now restrict the amount of data that we can store in the JWT to something like 1MB i think we already have a constant MAX_CUSTOM_METADATA_SIZE, which can be reused (re-refactored as a validation function, potentially in limits.ts)

But we also need to add the second part (on the upload route) to store the userMetadata received from the JWT. This way, the metadata can't be tempered.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will do a follow up PR to handle the upload part.

metadata: {
mimetype: contentType,
contentLength: contentLength,
},
})

return response.status(200).send({ url: signedUpload.url, token: signedUpload.token })
Expand Down
25 changes: 24 additions & 1 deletion src/http/routes/tus/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import http from 'http'
import { BaseLogger } from 'pino'
import { Upload } from '@tus/server'
import { Metadata, Upload } from '@tus/server'
import { randomUUID } from 'crypto'
import { TenantConnection } from '@internal/database'
import { ERRORS, isRenderableError } from '@internal/errors'
Expand Down Expand Up @@ -67,6 +67,20 @@ export async function onIncomingRequest(rawReq: Request, id: string) {

req.upload.resources = [`${uploadID.bucket}/${uploadID.objectName}`]

let customMd: Record<string, string> | undefined = undefined
const uploadMetadataHeader = req.headers['upload-metadata']

if (uploadMetadataHeader && typeof uploadMetadataHeader === 'string') {
try {
const parsedMetadata = Metadata.parse(uploadMetadataHeader)
if (parsedMetadata?.metadata) {
customMd = JSON.parse(parsedMetadata.metadata)
}
} catch (e) {
req.log.warn({ error: e }, 'Failed to parse user metadata')
}
}

// Handle signed url requests
if (req.url?.startsWith(`/upload/resumable/sign`)) {
const signature = req.headers['x-signature']
Expand Down Expand Up @@ -96,11 +110,20 @@ export async function onIncomingRequest(rawReq: Request, id: string) {
req.upload.storage.location
)

const uploadLength = req.headers['upload-length']
const contentLength = uploadLength ? Number(uploadLength) : undefined
const contentType = req.headers['content-type']

await uploader.canUpload({
owner: req.upload.owner,
bucketId: uploadID.bucket,
objectName: uploadID.objectName,
isUpsert: isUpsert,
userMetadata: customMd,
metadata: {
mimetype: contentType,
contentLength: contentLength,
},
})
}

Expand Down
1 change: 1 addition & 0 deletions src/internal/database/migrations/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,5 @@ export const DBMigration = {
'drop-index-lower-name': 53,
'drop-index-object-level': 54,
'prevent-direct-deletes': 55,
's3-multipart-uploads-metadata': 56,
}
3 changes: 2 additions & 1 deletion src/storage/database/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,8 @@ export interface Database {
version: string,
signature: string,
owner?: string,
metadata?: Record<string, string | null>
userMetadata?: Record<string, string | null>,
metadata?: Partial<ObjectMetadata>
): Promise<S3MultipartUpload>

findMultipartUpload(
Expand Down
6 changes: 4 additions & 2 deletions src/storage/database/knex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -889,7 +889,8 @@ export class StorageKnexDB implements Database {
version: string,
signature: string,
owner?: string,
metadata?: Record<string, string | null>
userMetadata?: Record<string, string | null>,
metadata?: Partial<ObjectMetadata>
) {
return this.runQuery('CreateMultipartUpload', async (knex) => {
const multipart = await knex
Expand All @@ -902,7 +903,8 @@ export class StorageKnexDB implements Database {
version,
upload_signature: signature,
owner_id: owner,
user_metadata: metadata,
user_metadata: userMetadata,
metadata: metadata,
})
)
.returning('*')
Expand Down
13 changes: 11 additions & 2 deletions src/storage/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { getJwtSecret } from '@internal/database'
import { ObjectMetadata, StorageBackendAdapter } from './backend'
import { Database, FindObjectFilters, SearchObjectOption } from './database'
import { mustBeValidKey } from './limits'
import { fileUploadFromRequest, Uploader, UploadRequest } from './uploader'
import { fileUploadFromRequest, Uploader, UploadRequest, CanUploadMetadata } from './uploader'
import { getConfig } from '../config'
import {
ObjectAdminDelete,
Expand Down Expand Up @@ -98,6 +98,7 @@ export class ObjectStorage {
owner: file.owner,
isUpsert: Boolean(file.isUpsert),
signal: file.signal,
userMetadata: uploadRequest.userMetadata,
})
}

Expand Down Expand Up @@ -339,6 +340,8 @@ export class ObjectStorage {
objectName: destinationKey,
owner,
isUpsert: upsert,
userMetadata: userMetadata,
metadata: destinationMetadata,
})

try {
Expand Down Expand Up @@ -792,14 +795,20 @@ export class ObjectStorage {
url: string,
expiresIn: number,
owner?: string,
options?: { upsert?: boolean }
options?: {
upsert?: boolean
userMetadata?: Record<string, unknown>
metadata?: CanUploadMetadata
}
) {
// check if user has INSERT permissions
await this.uploader.canUpload({
bucketId: this.bucketId,
objectName,
owner,
isUpsert: options?.upsert ?? false,
userMetadata: options?.userMetadata,
metadata: options?.metadata,
})

const { urlSigningKey } = await getJwtSecret(this.db.tenantId)
Expand Down
48 changes: 31 additions & 17 deletions src/storage/protocols/s3/s3-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,10 @@ export class S3ProtocolHandler {
objectName: command.Key as string,
isUpsert: true,
owner: this.owner,
userMetadata: command.Metadata,
metadata: {
mimetype: command.ContentType,
},
})

const uploadId = await this.storage.backend.createMultiPartUpload(
Expand Down Expand Up @@ -441,7 +445,8 @@ export class S3ProtocolHandler {
version,
signature,
this.owner,
command.Metadata
command.Metadata,
{ mimetype: command.ContentType }
)

return {
Expand Down Expand Up @@ -470,17 +475,19 @@ export class S3ProtocolHandler {
throw ERRORS.InvalidUploadId()
}

const multiPartUpload = await this.storage.db
.asSuperUser()
.findMultipartUpload(UploadId, 'id,version,user_metadata,metadata')

await uploader.canUpload({
bucketId: Bucket as string,
objectName: Key as string,
isUpsert: true,
owner: this.owner,
userMetadata: multiPartUpload.user_metadata || undefined,
metadata: multiPartUpload.metadata || undefined,
})

const multiPartUpload = await this.storage.db
.asSuperUser()
.findMultipartUpload(UploadId, 'id,version,user_metadata')

const parts = command.MultipartUpload?.Parts || []

if (parts.length === 0) {
Expand Down Expand Up @@ -578,15 +585,18 @@ export class S3ProtocolHandler {
const maxFileSize = await getFileSizeLimit(this.storage.db.tenantId, bucket?.file_size_limit)

const uploader = new Uploader(this.storage.backend, this.storage.db, this.storage.location)

const multipart = await this.shouldAllowPartUpload(UploadId, ContentLength, maxFileSize)

await uploader.canUpload({
bucketId: Bucket as string,
objectName: Key as string,
owner: this.owner,
isUpsert: true,
userMetadata: multipart.user_metadata || undefined,
metadata: multipart.metadata || undefined,
})

const multipart = await this.shouldAllowPartUpload(UploadId, ContentLength, maxFileSize)

if (signal?.aborted) {
throw ERRORS.AbortedTerminate('UploadPart aborted')
}
Expand Down Expand Up @@ -695,9 +705,9 @@ export class S3ProtocolHandler {
cacheControl: command.CacheControl!,
mimeType: command.ContentType!,
isTruncated: options.isTruncated,
userMetadata: command.Metadata,
},
objectName: command.Key as string,
userMetadata: command.Metadata,
owner: this.owner,
isUpsert: true,
uploadType: 's3',
Expand Down Expand Up @@ -735,14 +745,16 @@ export class S3ProtocolHandler {

const multipart = await this.storage.db
.asSuperUser()
.findMultipartUpload(UploadId, 'id,version')
.findMultipartUpload(UploadId, 'id,version,user_metadata,metadata')

const uploader = new Uploader(this.storage.backend, this.storage.db, this.storage.location)
await uploader.canUpload({
bucketId: Bucket,
objectName: Key,
owner: this.owner,
isUpsert: true,
userMetadata: multipart.user_metadata || undefined,
metadata: multipart.metadata || undefined,
})

await this.storage.backend.abortMultipartUpload(
Expand Down Expand Up @@ -1221,13 +1233,6 @@ export class S3ProtocolHandler {

const uploader = new Uploader(this.storage.backend, this.storage.db, this.storage.location)

await uploader.canUpload({
bucketId: Bucket,
objectName: Key,
owner: this.owner,
isUpsert: true,
})

const [destinationBucket] = await this.storage.db.asSuperUser().withTransaction(async (db) => {
return Promise.all([
db.findBucketById(Bucket, 'file_size_limit'),
Expand All @@ -1241,6 +1246,15 @@ export class S3ProtocolHandler {

const multipart = await this.shouldAllowPartUpload(UploadId, Number(copySize), maxFileSize)

await uploader.canUpload({
bucketId: Bucket,
objectName: Key,
owner: this.owner,
isUpsert: true,
userMetadata: multipart.user_metadata || undefined,
metadata: multipart.metadata || undefined,
})

const uploadPart = await this.storage.backend.uploadPartCopy(
storageS3Bucket,
this.storage.location.getKeyLocation({
Expand Down Expand Up @@ -1312,7 +1326,7 @@ export class S3ProtocolHandler {
return this.storage.db.asSuperUser().withTransaction(async (db) => {
const multipart = await db.findMultipartUpload(
uploadId,
'in_progress_size,version,upload_signature',
'in_progress_size,version,upload_signature,user_metadata,metadata',
{
forUpdate: true,
}
Expand Down
3 changes: 3 additions & 0 deletions src/storage/schemas/multipart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export const multipartUploadSchema = {
user_metadata: {
anyOf: [{ type: 'object', additionalProperties: true }, { type: 'null' }],
},
metadata: {
anyOf: [{ type: 'object', additionalProperties: true }, { type: 'null' }],
},
},
required: [
'id',
Expand Down
Loading
Loading