Skip to content

Commit d6db70e

Browse files
authored
feat: s3 post handler (#594)
1 parent f45ccaf commit d6db70e

17 files changed

+1353
-942
lines changed

package-lock.json

+745-660
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
@@ -79,6 +79,7 @@
7979
"xml2js": "^0.6.2"
8080
},
8181
"devDependencies": {
82+
"@aws-sdk/s3-presigned-post": "3.654.0",
8283
"@types/async-retry": "^1.4.5",
8384
"@types/busboy": "^1.3.0",
8485
"@types/crypto-js": "^4.1.1",

src/http/plugins/signature-v4.ts

+31-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { signJWT, verifyJWT } from '@internal/auth'
66
import { ERRORS } from '@internal/errors'
77

88
import { getConfig } from '../../config'
9+
import { MultipartFile } from '@fastify/multipart'
910

1011
const {
1112
anonKey,
@@ -25,10 +26,16 @@ const {
2526

2627
type AWSRequest = FastifyRequest<{ Querystring: { 'X-Amz-Credential'?: string } }>
2728

29+
declare module 'fastify' {
30+
interface FastifyRequest {
31+
multiPartFileStream?: MultipartFile
32+
}
33+
}
34+
2835
export const signatureV4 = fastifyPlugin(
2936
async function (fastify: FastifyInstance) {
3037
fastify.addHook('preHandler', async (request: AWSRequest) => {
31-
const clientSignature = extractSignature(request)
38+
const clientSignature = await extractSignature(request)
3239

3340
const sessionToken = clientSignature.sessionToken
3441

@@ -101,7 +108,7 @@ export const signatureV4 = fastifyPlugin(
101108
{ name: 'auth-signature-v4' }
102109
)
103110

104-
function extractSignature(req: AWSRequest) {
111+
async function extractSignature(req: AWSRequest) {
105112
if (typeof req.headers.authorization === 'string') {
106113
return SignatureV4.parseAuthorizationHeader(req.headers)
107114
}
@@ -110,6 +117,28 @@ function extractSignature(req: AWSRequest) {
110117
return SignatureV4.parseQuerySignature(req.query)
111118
}
112119

120+
if (typeof req.isMultipart === 'function' && req.isMultipart()) {
121+
const formData = new FormData()
122+
const data = await req.file({
123+
limits: {
124+
fields: 20,
125+
files: 1,
126+
},
127+
})
128+
129+
const fields = data?.fields
130+
if (fields) {
131+
for (const key in fields) {
132+
if (fields.hasOwnProperty(key) && (fields[key] as any).fieldname !== 'file') {
133+
formData.append(key, (fields[key] as any).value)
134+
}
135+
}
136+
}
137+
// Assign the multipartFileStream for later use
138+
req.multiPartFileStream = data
139+
return SignatureV4.parseMultipartSignature(formData)
140+
}
141+
113142
throw ERRORS.AccessDenied('Missing signature')
114143
}
115144

src/http/plugins/xml.ts

+6-3
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import xml from 'xml2js'
88
// @ts-ignore
99
import xmlBodyParser from 'fastify-xml-body-parser'
1010

11-
export const jsonToXml = fastifyPlugin(
11+
export const xmlParser = fastifyPlugin(
1212
async function (
1313
fastify: FastifyInstance,
1414
opts: { disableContentParser?: boolean; parseAsArray?: string[] }
@@ -17,16 +17,19 @@ export const jsonToXml = fastifyPlugin(
1717

1818
if (!opts.disableContentParser) {
1919
fastify.register(xmlBodyParser, {
20-
contentType: ['text/xml', 'application/xml', '*'],
20+
contentType: ['text/xml', 'application/xml'],
2121
isArray: (_: string, jpath: string) => {
2222
return opts.parseAsArray?.includes(jpath)
2323
},
2424
})
2525
}
26+
2627
fastify.addHook('preSerialization', async (req, res, payload) => {
2728
const accept = req.accepts()
2829

29-
if (accept.types(['application/xml', 'application/json']) === 'application/xml') {
30+
const acceptedTypes = ['application/xml', 'text/html']
31+
32+
if (acceptedTypes.some((allowed) => accept.types(acceptedTypes) === allowed)) {
3033
res.serializer((payload) => payload)
3134

3235
const xmlBuilder = new xml.Builder({

src/http/routes/object/createObject.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,15 @@ export default async function routes(fastify: FastifyInstance) {
7272
const objectName = request.params['*']
7373

7474
const isUpsert = request.headers['x-upsert'] === 'true'
75-
const owner = request.owner as string
75+
const owner = request.owner
7676

7777
const { objectMetadata, path, id } = await request.storage
7878
.from(bucketName)
79-
.uploadNewObject(request, {
79+
.uploadFromRequest(request, {
8080
objectName,
81-
owner,
82-
isUpsert,
8381
signal: request.signals.body.signal,
82+
owner: owner,
83+
isUpsert,
8484
})
8585

8686
return response.status(objectMetadata?.httpStatusCode ?? 200).send({

src/http/routes/object/updateObject.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { FromSchema } from 'json-schema-to-ts'
33
import { createDefaultSchema } from '../../routes-helper'
44
import { ROUTE_OPERATIONS } from '../operations'
55
import fastifyMultipart from '@fastify/multipart'
6+
import { fileUploadFromRequest } from '@storage/uploader'
67

78
const updateObjectParamsSchema = {
89
type: 'object',
@@ -74,10 +75,11 @@ export default async function routes(fastify: FastifyInstance) {
7475

7576
const { objectMetadata, path, id } = await request.storage
7677
.from(bucketName)
77-
.uploadOverridingObject(request, {
78-
owner,
79-
objectName: objectName,
78+
.uploadFromRequest(request, {
79+
objectName,
8080
signal: request.signals.body.signal,
81+
owner: owner,
82+
isUpsert: true,
8183
})
8284

8385
return response.status(objectMetadata?.httpStatusCode ?? 200).send({

src/http/routes/object/uploadSignedObject.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ export default async function routes(fastify: FastifyInstance) {
9090
const { objectMetadata, path } = await request.storage
9191
.asSuperUser()
9292
.from(bucketName)
93-
.uploadNewObject(request, {
93+
.uploadFromRequest(request, {
9494
owner,
9595
objectName,
9696
isUpsert: upsert,
+157
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { S3ProtocolHandler } from '@storage/protocols/s3/s3-handler'
2+
import { S3Router } from '../router'
3+
import { ROUTE_OPERATIONS } from '../../operations'
4+
import { Multipart, MultipartValue } from '@fastify/multipart'
5+
import { fileUploadFromRequest, getStandardMaxFileSizeLimit } from '@storage/uploader'
6+
import { ERRORS } from '@internal/errors'
7+
import { pipeline } from 'stream/promises'
8+
import { ByteLimitTransformStream } from '@storage/protocols/s3/byte-limit-stream'
9+
import stream from 'stream'
10+
11+
const PutObjectInput = {
12+
summary: 'Put Object',
13+
Params: {
14+
type: 'object',
15+
properties: {
16+
Bucket: { type: 'string' },
17+
'*': { type: 'string' },
18+
},
19+
required: ['Bucket', '*'],
20+
},
21+
Querystring: {
22+
type: 'object',
23+
},
24+
Headers: {
25+
type: 'object',
26+
properties: {
27+
authorization: { type: 'string' },
28+
host: { type: 'string' },
29+
'x-amz-content-sha256': { type: 'string' },
30+
'x-amz-date': { type: 'string' },
31+
'content-type': { type: 'string' },
32+
'content-length': { type: 'integer' },
33+
'cache-control': { type: 'string' },
34+
'content-disposition': { type: 'string' },
35+
'content-encoding': { type: 'string' },
36+
expires: { type: 'string' },
37+
},
38+
required: ['content-length'],
39+
},
40+
} as const
41+
42+
const PostFormInput = {
43+
summary: 'PostForm Object',
44+
Params: {
45+
type: 'object',
46+
properties: {
47+
Bucket: { type: 'string' },
48+
},
49+
required: ['Bucket'],
50+
},
51+
} as const
52+
53+
export default function PutObject(s3Router: S3Router) {
54+
s3Router.put(
55+
'/:Bucket/*',
56+
{
57+
schema: PutObjectInput,
58+
operation: ROUTE_OPERATIONS.S3_UPLOAD,
59+
disableContentTypeParser: true,
60+
},
61+
async (req, ctx) => {
62+
const s3Protocol = new S3ProtocolHandler(ctx.storage, ctx.tenantId, ctx.owner)
63+
64+
const metadata = s3Protocol.parseMetadataHeaders(req.Headers)
65+
const contentLength = req.Headers['content-length']
66+
let key = req.Params['*']
67+
68+
if (key.endsWith('/') && contentLength === 0) {
69+
// Consistent with how supabase Storage handles empty folders
70+
key += '.emptyFolderPlaceholder'
71+
}
72+
73+
const bucket = await ctx.storage
74+
.asSuperUser()
75+
.findBucket(req.Params.Bucket, 'id,file_size_limit,allowed_mime_types')
76+
77+
const uploadRequest = await fileUploadFromRequest(ctx.req, {
78+
objectName: key,
79+
allowedMimeTypes: bucket.allowed_mime_types || [],
80+
fileSizeLimit: bucket.file_size_limit || undefined,
81+
})
82+
83+
return s3Protocol.putObject(
84+
{
85+
Body: uploadRequest.body,
86+
Bucket: req.Params.Bucket,
87+
Key: key,
88+
CacheControl: uploadRequest.cacheControl,
89+
ContentType: uploadRequest.mimeType,
90+
Expires: req.Headers?.['expires'] ? new Date(req.Headers?.['expires']) : undefined,
91+
ContentEncoding: req.Headers?.['content-encoding'],
92+
Metadata: metadata,
93+
},
94+
{ signal: ctx.signals.body, isTruncated: uploadRequest.isTruncated }
95+
)
96+
}
97+
)
98+
99+
s3Router.post(
100+
'/:Bucket|content-type=multipart/form-data',
101+
{
102+
schema: PostFormInput,
103+
operation: ROUTE_OPERATIONS.S3_UPLOAD,
104+
acceptMultiformData: true,
105+
},
106+
async (req, ctx) => {
107+
const s3Protocol = new S3ProtocolHandler(ctx.storage, ctx.tenantId, ctx.owner)
108+
109+
const file = ctx.req.multiPartFileStream
110+
111+
if (!file) {
112+
throw ERRORS.InvalidParameter('Missing file')
113+
}
114+
115+
const bucket = await ctx.storage
116+
.asSuperUser()
117+
.findBucket(req.Params.Bucket, 'id,file_size_limit,allowed_mime_types')
118+
119+
const metadata = s3Protocol.parseMetadataHeaders(file?.fields || {})
120+
const expiresField = normaliseFormDataField(file?.fields?.Expires) as string | undefined
121+
122+
const maxFileSize = await getStandardMaxFileSizeLimit(ctx.tenantId, bucket.file_size_limit)
123+
124+
return pipeline(file.file, new ByteLimitTransformStream(maxFileSize), async (fileStream) => {
125+
return s3Protocol.putObject(
126+
{
127+
Body: fileStream as stream.Readable,
128+
Bucket: req.Params.Bucket,
129+
Key: normaliseFormDataField(file?.fields?.key) as string,
130+
CacheControl: normaliseFormDataField(file?.fields?.['Cache-Control']) as string,
131+
ContentType: normaliseFormDataField(file?.fields?.['Content-Type']) as string,
132+
Expires: expiresField ? new Date(expiresField) : undefined,
133+
ContentEncoding: normaliseFormDataField(file?.fields?.['Content-Encoding']) as string,
134+
Metadata: metadata,
135+
},
136+
{ signal: ctx.signals.body, isTruncated: () => file.file.truncated }
137+
)
138+
})
139+
}
140+
)
141+
}
142+
143+
function normaliseFormDataField(value: Multipart | Multipart[] | undefined) {
144+
if (!value) {
145+
return undefined
146+
}
147+
148+
if (Array.isArray(value)) {
149+
return (value[0] as MultipartValue).value as string
150+
}
151+
152+
if (value.type === 'field') {
153+
return value.value
154+
}
155+
156+
return value.file
157+
}

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

-58
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,6 @@ import { S3ProtocolHandler } from '@storage/protocols/s3/s3-handler'
22
import { S3Router } from '../router'
33
import { ROUTE_OPERATIONS } from '../../operations'
44

5-
const PutObjectInput = {
6-
summary: 'Put Object',
7-
Params: {
8-
type: 'object',
9-
properties: {
10-
Bucket: { type: 'string' },
11-
'*': { type: 'string' },
12-
},
13-
required: ['Bucket', '*'],
14-
},
15-
Querystring: {
16-
type: 'object',
17-
},
18-
Headers: {
19-
type: 'object',
20-
properties: {
21-
authorization: { type: 'string' },
22-
host: { type: 'string' },
23-
'x-amz-content-sha256': { type: 'string' },
24-
'x-amz-date': { type: 'string' },
25-
'content-type': { type: 'string' },
26-
'content-length': { type: 'integer' },
27-
'cache-control': { type: 'string' },
28-
'content-disposition': { type: 'string' },
29-
'content-encoding': { type: 'string' },
30-
expires: { type: 'string' },
31-
},
32-
},
33-
} as const
34-
355
const UploadPartInput = {
366
summary: 'Upload Part',
377
Params: {
@@ -84,32 +54,4 @@ export default function UploadPart(s3Router: S3Router) {
8454
})
8555
}
8656
)
87-
88-
s3Router.put(
89-
'/:Bucket/*',
90-
{
91-
schema: PutObjectInput,
92-
operation: ROUTE_OPERATIONS.S3_UPLOAD,
93-
disableContentTypeParser: true,
94-
},
95-
(req, ctx) => {
96-
const s3Protocol = new S3ProtocolHandler(ctx.storage, ctx.tenantId, ctx.owner)
97-
98-
const metadata = s3Protocol.parseMetadataHeaders(req.Headers)
99-
100-
return s3Protocol.putObject(
101-
{
102-
Body: ctx.req as any,
103-
Bucket: req.Params.Bucket,
104-
Key: req.Params['*'],
105-
CacheControl: req.Headers?.['cache-control'],
106-
ContentType: req.Headers?.['content-type'],
107-
Expires: req.Headers?.['expires'] ? new Date(req.Headers?.['expires']) : undefined,
108-
ContentEncoding: req.Headers?.['content-encoding'],
109-
Metadata: metadata,
110-
},
111-
ctx.signals.body
112-
)
113-
}
114-
)
11557
}

0 commit comments

Comments
 (0)