Skip to content

Commit d0f0348

Browse files
authored
feat: custom metadata on upload (#518)
1 parent 8347d13 commit d0f0348

31 files changed

+555
-150
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE storage.objects ADD COLUMN user_metadata jsonb NULL;
2+
ALTER TABLE storage.s3_multipart_uploads ADD COLUMN user_metadata jsonb NULL;

package-lock.json

Lines changed: 39 additions & 39 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,9 @@
4444
"@opentelemetry/instrumentation-pino": "^0.39.0",
4545
"@shopify/semaphore": "^3.0.2",
4646
"@smithy/node-http-handler": "^2.3.1",
47-
"@tus/file-store": "1.3.1",
48-
"@tus/s3-store": "1.4.1",
49-
"@tus/server": "1.4.1",
47+
"@tus/file-store": "1.4.0",
48+
"@tus/s3-store": "1.5.0",
49+
"@tus/server": "1.7.0",
5050
"agentkeepalive": "^4.5.0",
5151
"ajv": "^8.12.0",
5252
"async-retry": "^1.3.3",

src/http/routes/object/copyObject.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const copyRequestBodySchema = {
1111
sourceKey: { type: 'string', examples: ['folder/source.png'] },
1212
destinationBucket: { type: 'string', examples: ['users'] },
1313
destinationKey: { type: 'string', examples: ['folder/destination.png'] },
14+
copyMetadata: { type: 'boolean', examples: [true] },
1415
},
1516
required: ['sourceKey', 'bucketId', 'destinationKey'],
1617
} as const
@@ -51,9 +52,13 @@ export default async function routes(fastify: FastifyInstance) {
5152

5253
const destinationBucketId = destinationBucket || bucketId
5354

54-
const result = await request.storage
55-
.from(bucketId)
56-
.copyObject(sourceKey, destinationBucketId, destinationKey, request.owner)
55+
const result = await request.storage.from(bucketId).copyObject({
56+
sourceKey,
57+
destinationBucket: destinationBucketId,
58+
destinationKey,
59+
owner: request.owner,
60+
copyMetadata: request.body.copyMetadata ?? true,
61+
})
5762

5863
return response.status(result.httpStatusCode ?? 200).send({
5964
Id: result.destObject.id,

src/http/routes/object/getObjectInfo.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ async function requestHandler(
3030
getObjectRequestInterface,
3131
unknown
3232
>,
33-
publicRoute = false
33+
publicRoute = false,
34+
method: 'head' | 'info' = 'head'
3435
) {
3536
const { bucketName } = request.params
3637
const objectName = request.params['*']
@@ -42,15 +43,21 @@ async function requestHandler(
4243
await request.storage.asSuperUser().findBucket(bucketName, 'id', {
4344
isPublic: true,
4445
})
45-
obj = await request.storage.asSuperUser().from(bucketName).findObject(objectName, 'id,version')
46+
obj = await request.storage
47+
.asSuperUser()
48+
.from(bucketName)
49+
.findObject(objectName, 'id,version,metadata,user_metadata,created_at')
4650
} else {
47-
obj = await request.storage.from(bucketName).findObject(objectName, 'id,version')
51+
obj = await request.storage
52+
.from(bucketName)
53+
.findObject(objectName, 'id,version,metadata,user_metadata,created_at')
4854
}
4955

50-
return request.storage.renderer('head').render(request, response, {
56+
return request.storage.renderer(method).render(request, response, {
5157
bucket: storageS3Bucket,
5258
key: s3Key,
5359
version: obj.version,
60+
object: obj,
5461
})
5562
}
5663

@@ -90,7 +97,7 @@ export async function publicRoutes(fastify: FastifyInstance) {
9097
},
9198
},
9299
async (request, response) => {
93-
return requestHandler(request, response, true)
100+
return requestHandler(request, response, true, 'info')
94101
}
95102
)
96103
}
@@ -131,7 +138,7 @@ export async function authenticatedRoutes(fastify: FastifyInstance) {
131138
},
132139
},
133140
async (request, response) => {
134-
return requestHandler(request, response)
141+
return requestHandler(request, response, false, 'info')
135142
}
136143
)
137144

@@ -151,7 +158,7 @@ export async function authenticatedRoutes(fastify: FastifyInstance) {
151158
},
152159
},
153160
async (request, response) => {
154-
return requestHandler(request, response)
161+
return requestHandler(request, response, false, 'info')
155162
}
156163
)
157164

src/http/routes/s3/commands/create-multipart-upload.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const CreateMultiPartUploadInput = {
2121
},
2222
Headers: {
2323
type: 'object',
24+
additionalProperties: true,
2425
properties: {
2526
authorization: { type: 'string' },
2627
'content-type': { type: 'string' },
@@ -39,13 +40,16 @@ export default function CreateMultipartUpload(s3Router: S3Router) {
3940
(req, ctx) => {
4041
const s3Protocol = new S3ProtocolHandler(ctx.storage, ctx.tenantId, ctx.owner)
4142

43+
const metadata = s3Protocol.parseMetadataHeaders(req.Headers)
44+
4245
return s3Protocol.createMultiPartUpload({
4346
Bucket: req.Params.Bucket,
4447
Key: req.Params['*'],
4548
ContentType: req.Headers?.['content-type'],
4649
CacheControl: req.Headers?.['cache-control'],
4750
ContentDisposition: req.Headers?.['content-disposition'],
4851
ContentEncoding: req.Headers?.['content-encoding'],
52+
Metadata: metadata,
4953
})
5054
}
5155
)

src/http/routes/s3/commands/upload-part.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ export default function UploadPart(s3Router: S3Router) {
9494
},
9595
(req, ctx) => {
9696
const s3Protocol = new S3ProtocolHandler(ctx.storage, ctx.tenantId, ctx.owner)
97+
98+
const metadata = s3Protocol.parseMetadataHeaders(req.Headers)
99+
97100
return s3Protocol.putObject({
98101
Body: ctx.req as any,
99102
Bucket: req.Params.Bucket,
@@ -102,6 +105,7 @@ export default function UploadPart(s3Router: S3Router) {
102105
ContentType: req.Headers?.['content-type'],
103106
Expires: req.Headers?.['expires'] ? new Date(req.Headers?.['expires']) : undefined,
104107
ContentEncoding: req.Headers?.['content-encoding'],
108+
Metadata: metadata,
105109
})
106110
}
107111
)

src/http/routes/tus/lifecycle.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export async function onCreate(
154154
rawReq: http.IncomingMessage,
155155
res: http.ServerResponse,
156156
upload: Upload
157-
): Promise<http.ServerResponse> {
157+
): Promise<{ res: http.ServerResponse; metadata?: Upload['metadata'] }> {
158158
const uploadID = UploadId.fromString(upload.id)
159159

160160
const req = rawReq as MultiPartRequest
@@ -166,17 +166,21 @@ export async function onCreate(
166166

167167
const uploader = new Uploader(storage.backend, storage.db)
168168

169-
if (upload.metadata && /^-?\d+$/.test(upload.metadata.cacheControl || '')) {
170-
upload.metadata.cacheControl = `max-age=${upload.metadata.cacheControl}`
171-
} else if (upload.metadata) {
172-
upload.metadata.cacheControl = 'no-cache'
169+
const metadata = {
170+
...(upload.metadata ? upload.metadata : {}),
173171
}
174172

175-
if (upload.metadata?.contentType && bucket.allowed_mime_types) {
176-
uploader.validateMimeType(upload.metadata.contentType, bucket.allowed_mime_types)
173+
if (/^-?\d+$/.test(metadata.cacheControl || '')) {
174+
metadata.cacheControl = `max-age=${metadata.cacheControl}`
175+
} else if (metadata) {
176+
metadata.cacheControl = 'no-cache'
177177
}
178178

179-
return res
179+
if (metadata?.contentType && bucket.allowed_mime_types) {
180+
uploader.validateMimeType(metadata.contentType, bucket.allowed_mime_types)
181+
}
182+
183+
return { res, metadata }
180184
}
181185

182186
/**
@@ -199,6 +203,14 @@ export async function onUploadFinish(
199203
)
200204

201205
const uploader = new Uploader(req.upload.storage.backend, req.upload.storage.db)
206+
let customMd: undefined | Record<string, string> = undefined
207+
if (upload.metadata?.userMetadata) {
208+
try {
209+
customMd = JSON.parse(upload.metadata.userMetadata)
210+
} catch (e) {
211+
// no-op
212+
}
213+
}
202214

203215
await uploader.completeUpload({
204216
version: resourceId.version,
@@ -208,6 +220,7 @@ export async function onUploadFinish(
208220
isUpsert: req.upload.isUpsert,
209221
uploadType: 'resumable',
210222
owner: req.upload.owner,
223+
userMetadata: customMd,
211224
})
212225

213226
res.setHeader('Tus-Complete', '1')

src/internal/database/connection.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ export const connections = new TTLCache<string, Knex>({
5353
if (!pool) return
5454
try {
5555
await pool.destroy()
56-
pool.client.removeAllListeners()
5756
} catch (e) {
5857
logSchema.error(logger, 'pool was not able to be destroyed', {
5958
type: 'db',
@@ -155,7 +154,6 @@ export class TenantConnection {
155154
async dispose() {
156155
if (this.options.isExternalPool) {
157156
await this.pool.destroy()
158-
this.pool.client.removeAllListeners()
159157
}
160158
}
161159

@@ -201,7 +199,13 @@ export class TenantConnection {
201199
// This should never be reached, since the above promise is always rejected in this edge case.
202200
throw ERRORS.DatabaseError('Transaction already completed')
203201
}
204-
await tnx.raw(`SELECT set_config('search_path', ?, true)`, [searchPath.join(', ')])
202+
203+
try {
204+
await tnx.raw(`SELECT set_config('search_path', ?, true)`, [searchPath.join(', ')])
205+
} catch (e) {
206+
await tnx.rollback()
207+
throw e
208+
}
205209
}
206210

207211
return tnx

0 commit comments

Comments
 (0)