Skip to content

Commit ddae3be

Browse files
authored
feat(object-info): custom HEAD request for object info (#205)
1 parent e29c5d1 commit ddae3be

File tree

9 files changed

+225
-2
lines changed

9 files changed

+225
-2
lines changed

package-lock.json

+17
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"fs-xattr": "^0.3.1",
4343
"jsonwebtoken": "^8.5.1",
4444
"knex": "^1.0.3",
45+
"md5-file": "^5.0.0",
4546
"pg": "^8.7.3",
4647
"pg-listen": "^1.7.0",
4748
"pino": "^8.2.0",

src/backend/file.ts

+13
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ObjectMetadata, ObjectResponse } from '../types/types'
22
import xattr from 'fs-xattr'
33
import fs from 'fs-extra'
44
import path from 'path'
5+
import fileChecksum from 'md5-file'
56
import { promisify } from 'util'
67
import stream from 'stream'
78
import { getConfig } from '../utils/config'
@@ -39,6 +40,7 @@ export class FileBackend implements GenericStorageBackend {
3940
const contentType = await this.getMetadata(file, 'user.supabase.content-type')
4041
const lastModified = new Date(0)
4142
lastModified.setUTCMilliseconds(data.mtimeMs)
43+
4244
return {
4345
metadata: {
4446
cacheControl,
@@ -101,9 +103,20 @@ export class FileBackend implements GenericStorageBackend {
101103
async headObject(bucket: string, key: string): Promise<ObjectMetadata> {
102104
const file = path.resolve(this.filePath, `${bucket}/${key}`)
103105
const data = await fs.stat(file)
106+
const cacheControl = await this.getMetadata(file, 'user.supabase.cache-control')
107+
const contentType = await this.getMetadata(file, 'user.supabase.content-type')
108+
const lastModified = new Date(0)
109+
lastModified.setUTCMilliseconds(data.mtimeMs)
110+
111+
const checksum = await fileChecksum(file)
112+
104113
return {
105114
httpStatusCode: 200,
106115
size: data.size,
116+
cacheControl,
117+
mimetype: contentType,
118+
eTag: `"${checksum}"`,
119+
lastModified: data.birthtime,
107120
}
108121
}
109122

src/backend/s3.ts

+4
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@ export class S3Backend implements GenericStorageBackend {
142142
return {
143143
httpStatusCode: data.$metadata.httpStatusCode,
144144
size: data.ContentLength,
145+
eTag: data.ETag,
146+
cacheControl: data.CacheControl,
147+
lastModified: data.LastModified,
148+
mimetype: data.ContentType,
145149
}
146150
}
147151

src/routes/object/getObject.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export default async function routes(fastify: FastifyInstance) {
126126
fastify.get<getObjectRequestInterface>(
127127
'/authenticated/:bucketName/*',
128128
{
129-
exposeHeadRoute: true,
129+
exposeHeadRoute: false,
130130
// @todo add success response schema here
131131
schema: {
132132
params: getObjectParamsSchema,
@@ -144,7 +144,7 @@ export default async function routes(fastify: FastifyInstance) {
144144
fastify.get<getObjectRequestInterface>(
145145
'/:bucketName/*',
146146
{
147-
exposeHeadRoute: true,
147+
exposeHeadRoute: false,
148148
// @todo add success response schema here
149149
schema: {
150150
params: getObjectParamsSchema,

src/routes/object/getObjectInfo.ts

+146
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
2+
import { FromSchema } from 'json-schema-to-ts'
3+
import { IncomingMessage, Server, ServerResponse } from 'http'
4+
import { AuthenticatedRangeRequest, Obj } from '../../types/types'
5+
import { isValidKey, transformPostgrestError } from '../../utils'
6+
import { getConfig } from '../../utils/config'
7+
import { normalizeContentType } from '../../utils'
8+
import { createResponse } from '../../utils/generic-routes'
9+
import { S3Backend } from '../../backend/s3'
10+
import { FileBackend } from '../../backend/file'
11+
import { GenericStorageBackend } from '../../backend/generic'
12+
13+
const { region, globalS3Bucket, globalS3Endpoint, storageBackendType } = getConfig()
14+
let storageBackend: GenericStorageBackend
15+
16+
if (storageBackendType === 'file') {
17+
storageBackend = new FileBackend()
18+
} else {
19+
storageBackend = new S3Backend(region, globalS3Endpoint)
20+
}
21+
22+
const getObjectParamsSchema = {
23+
type: 'object',
24+
properties: {
25+
bucketName: { type: 'string', examples: ['avatars'] },
26+
'*': { type: 'string', examples: ['folder/cat.png'] },
27+
},
28+
required: ['bucketName', '*'],
29+
} as const
30+
31+
interface getObjectRequestInterface extends AuthenticatedRangeRequest {
32+
Params: FromSchema<typeof getObjectParamsSchema>
33+
}
34+
35+
async function requestHandler(
36+
request: FastifyRequest<getObjectRequestInterface, Server, IncomingMessage>,
37+
response: FastifyReply<
38+
Server,
39+
IncomingMessage,
40+
ServerResponse,
41+
getObjectRequestInterface,
42+
unknown
43+
>,
44+
publicRoute = false
45+
) {
46+
const { bucketName } = request.params
47+
const objectName = request.params['*']
48+
49+
if (!isValidKey(objectName) || !isValidKey(bucketName)) {
50+
return response
51+
.status(400)
52+
.send(createResponse('The key contains invalid characters', '400', 'Invalid key'))
53+
}
54+
55+
const postgrest = publicRoute ? request.superUserPostgrest : request.postgrest
56+
const objectResponse = await postgrest
57+
.from<Obj>('objects')
58+
.select('id')
59+
.match({
60+
name: objectName,
61+
bucket_id: bucketName,
62+
})
63+
.single()
64+
65+
if (objectResponse.error) {
66+
const { status, error } = objectResponse
67+
request.log.error({ error }, 'error object')
68+
return response.status(400).send(transformPostgrestError(error, status))
69+
}
70+
71+
const s3Key = `${request.tenantId}/${bucketName}/${objectName}`
72+
73+
try {
74+
const data = await storageBackend.headObject(globalS3Bucket, s3Key)
75+
76+
response
77+
.status(data.httpStatusCode ?? 200)
78+
.header('Content-Type', normalizeContentType(data.mimetype))
79+
.header('Cache-Control', data.cacheControl)
80+
.header('Content-Length', data.size)
81+
.header('ETag', data.eTag)
82+
.header('Last-Modified', data.lastModified?.toUTCString())
83+
84+
return response.send()
85+
} catch (err: any) {
86+
if (err.$metadata?.httpStatusCode === 304) {
87+
return response.status(304).send()
88+
}
89+
request.log.error(err)
90+
return response.status(400).send(createResponse(err.message, '400', err.name))
91+
}
92+
}
93+
94+
export async function publicRoutes(fastify: FastifyInstance) {
95+
fastify.head<getObjectRequestInterface>(
96+
'/public/:bucketName/*',
97+
{
98+
schema: {
99+
params: getObjectParamsSchema,
100+
headers: { $ref: 'authSchema#' },
101+
summary: 'Get object info',
102+
description: 'returns object info',
103+
response: { '4xx': { $ref: 'errorSchema#' } },
104+
},
105+
},
106+
async (request, response) => {
107+
return requestHandler(request, response, true)
108+
}
109+
)
110+
}
111+
112+
export async function authenticatedRoutes(fastify: FastifyInstance) {
113+
const summary = 'Retrieve object info'
114+
fastify.head<getObjectRequestInterface>(
115+
'/authenticated/:bucketName/*',
116+
{
117+
schema: {
118+
params: getObjectParamsSchema,
119+
headers: { $ref: 'authSchema#' },
120+
summary,
121+
response: { '4xx': { $ref: 'errorSchema#', description: 'Error response' } },
122+
tags: ['object'],
123+
},
124+
},
125+
async (request, response) => {
126+
return requestHandler(request, response)
127+
}
128+
)
129+
130+
fastify.head<getObjectRequestInterface>(
131+
'/:bucketName/*',
132+
{
133+
schema: {
134+
params: getObjectParamsSchema,
135+
headers: { $ref: 'authSchema#' },
136+
summary,
137+
description: 'use HEAD /object/authenticated/{bucketName} instead',
138+
response: { '4xx': { $ref: 'errorSchema#' } },
139+
tags: ['deprecated'],
140+
},
141+
},
142+
async (request, response) => {
143+
return requestHandler(request, response)
144+
}
145+
)
146+
}

src/routes/object/getPublicObject.ts

+1
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export default async function routes(fastify: FastifyInstance) {
4848
'/public/:bucketName/*',
4949
{
5050
// @todo add success response schema here
51+
exposeHeadRoute: false,
5152
schema: {
5253
params: getPublicObjectParamsSchema,
5354
summary,

src/routes/object/index.ts

+6
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import getSignedURLs from './getSignedURLs'
1313
import listObjects from './listObjects'
1414
import moveObject from './moveObject'
1515
import updateObject from './updateObject'
16+
import {
17+
publicRoutes as getObjectInfoPublic,
18+
authenticatedRoutes as getObjectInfoAuth,
19+
} from './getObjectInfo'
1620

1721
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
1822
export default async function routes(fastify: FastifyInstance) {
@@ -28,6 +32,7 @@ export default async function routes(fastify: FastifyInstance) {
2832
fastify.register(moveObject)
2933
fastify.register(updateObject)
3034
fastify.register(listObjects)
35+
fastify.register(getObjectInfoAuth)
3136

3237
fastify.register(async (fastify) => {
3338
fastify.register(superUserPostgrest)
@@ -43,5 +48,6 @@ export default async function routes(fastify: FastifyInstance) {
4348
fastify.register(superUserPostgrest)
4449

4550
fastify.register(getPublicObject)
51+
fastify.register(getObjectInfoPublic)
4652
})
4753
}

src/test/object.test.ts

+35
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ beforeEach(() => {
5858
httpStatusCode: 200,
5959
size: 3746,
6060
mimetype: 'image/png',
61+
eTag: 'abc',
62+
cacheControl: 'no-cache',
63+
lastModified: new Date('Wed, 12 Oct 2022 11:17:02 GMT'),
6164
})
6265
})
6366

@@ -106,6 +109,38 @@ describe('testing GET object', () => {
106109
})
107110
})
108111

112+
test('get authenticated object info', async () => {
113+
const response = await app().inject({
114+
method: 'HEAD',
115+
url: '/object/authenticated/bucket2/authenticated/casestudy.png',
116+
headers: {
117+
authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`,
118+
},
119+
})
120+
expect(response.statusCode).toBe(200)
121+
expect(response.headers['etag']).toBe('abc')
122+
expect(response.headers['last-modified']).toBe('Wed, 12 Oct 2022 11:17:02 GMT')
123+
expect(response.headers['content-length']).toBe(3746)
124+
expect(response.headers['cache-control']).toBe('no-cache')
125+
expect(S3Backend.prototype.headObject).toBeCalled()
126+
})
127+
128+
test('get public object info', async () => {
129+
const response = await app().inject({
130+
method: 'HEAD',
131+
url: '/object/public/public-bucket-2/favicon.ico',
132+
headers: {
133+
authorization: ``,
134+
},
135+
})
136+
expect(response.statusCode).toBe(200)
137+
expect(response.headers['etag']).toBe('abc')
138+
expect(response.headers['last-modified']).toBe('Wed, 12 Oct 2022 11:17:02 GMT')
139+
expect(response.headers['content-length']).toBe(3746)
140+
expect(response.headers['cache-control']).toBe('no-cache')
141+
expect(S3Backend.prototype.headObject).toBeCalled()
142+
})
143+
109144
test('force downloading file with default name', async () => {
110145
const response = await app().inject({
111146
method: 'GET',

0 commit comments

Comments
 (0)