Skip to content

Commit 53f4448

Browse files
authored
feat(file-manager): multipart upload and progress bar (#3232)
1 parent 948cd1e commit 53f4448

File tree

43 files changed

+1293
-330
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+1293
-330
lines changed
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import S3 from "aws-sdk/clients/s3";
2+
3+
interface CompleteMultiPartUploadParams {
4+
fileKey: string;
5+
uploadId: string;
6+
}
7+
8+
interface GetAllUploadPartsParams {
9+
Bucket: string;
10+
Key: string;
11+
UploadId: string;
12+
}
13+
14+
export class CompleteMultiPartUploadUseCase {
15+
private readonly s3: S3;
16+
private readonly bucket: string;
17+
18+
constructor(bucket: string, s3Client: S3) {
19+
this.bucket = bucket;
20+
this.s3 = s3Client;
21+
}
22+
23+
async execute(params: CompleteMultiPartUploadParams) {
24+
const uploadParams = {
25+
Bucket: this.bucket,
26+
Key: params.fileKey,
27+
UploadId: params.uploadId
28+
};
29+
30+
const allParts = await this.getAllUploadParts(uploadParams);
31+
32+
const s3Params = {
33+
...uploadParams,
34+
MultipartUpload: {
35+
Parts: allParts
36+
}
37+
};
38+
39+
return new Promise<void>((resolve, reject) => {
40+
this.s3.completeMultipartUpload(s3Params, (err, data) => {
41+
if (err) {
42+
console.error(err);
43+
reject(err);
44+
return;
45+
}
46+
47+
console.log(data);
48+
resolve();
49+
});
50+
});
51+
}
52+
53+
private async getAllUploadParts(params: GetAllUploadPartsParams) {
54+
const parts: S3.Parts = [];
55+
56+
let marker: number | undefined = undefined;
57+
while (true) {
58+
const { Parts, PartNumberMarker }: S3.ListPartsOutput = await this.s3
59+
.listParts({
60+
...params,
61+
PartNumberMarker: marker
62+
})
63+
.promise();
64+
65+
if (Parts) {
66+
Parts.forEach(part => parts.push(part));
67+
}
68+
69+
marker = PartNumberMarker || undefined;
70+
if (!marker) {
71+
break;
72+
}
73+
}
74+
75+
return parts.map(part => ({
76+
ETag: part.ETag as string,
77+
PartNumber: part.PartNumber as number
78+
}));
79+
}
80+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import S3 from "aws-sdk/clients/s3";
2+
import { prepareFileData } from "~/utils/prepareFileData";
3+
4+
interface CreateMultiPartUploadParams {
5+
file: {
6+
name: string;
7+
type: string;
8+
size: number;
9+
};
10+
numberOfParts: number;
11+
}
12+
13+
export class CreateMultiPartUploadUseCase {
14+
private readonly s3: S3;
15+
private readonly bucket: string;
16+
17+
constructor(bucket: string, s3Client: S3) {
18+
this.bucket = bucket;
19+
this.s3 = s3Client;
20+
}
21+
22+
async execute(params: CreateMultiPartUploadParams) {
23+
const file = prepareFileData(params.file);
24+
25+
const s3Params = { Bucket: this.bucket, Key: file.key };
26+
27+
const { UploadId } = await this.s3.createMultipartUpload(s3Params).promise();
28+
29+
const parts = await Promise.all(
30+
Array.from({ length: params.numberOfParts }).map((_, index) => {
31+
return this.s3
32+
.getSignedUrlPromise("uploadPart", {
33+
...s3Params,
34+
UploadId,
35+
PartNumber: index + 1,
36+
// URL expires after 24 hours.
37+
Expires: 86400
38+
})
39+
.then(url => ({
40+
url,
41+
partNumber: index + 1
42+
}));
43+
})
44+
);
45+
46+
return {
47+
file,
48+
uploadId: UploadId,
49+
parts
50+
};
51+
}
52+
}

packages/api-file-manager-s3/src/plugins/graphqlFileStorageS3.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import S3 from "aws-sdk/clients/s3";
12
import { GraphQLSchemaPlugin } from "@webiny/handler-graphql/types";
23
import { ErrorResponse, Response } from "@webiny/handler-graphql/responses";
34
import { FileManagerContext } from "@webiny/api-file-manager/types";
45
import { getPresignedPostPayload } from "~/utils/getPresignedPostPayload";
56
import WebinyError from "@webiny/error";
67
import { checkPermission } from "~/plugins/checkPermission";
78
import { PresignedPostPayloadData } from "~/types";
9+
import { CreateMultiPartUploadUseCase } from "~/multiPartUpload/CreateMultiPartUploadUseCase";
10+
import { CompleteMultiPartUploadUseCase } from "~/multiPartUpload/CompleteMultiPartUploadUseCase";
811

912
const plugin: GraphQLSchemaPlugin<FileManagerContext> = {
1013
type: "graphql-schema",
@@ -36,6 +39,22 @@ const plugin: GraphQLSchemaPlugin<FileManagerContext> = {
3639
data: GetPreSignedPostPayloadResponseData
3740
}
3841
42+
type MultiPartUploadFilePart {
43+
partNumber: Int!
44+
url: String!
45+
}
46+
47+
type CreateMultiPartUploadResponseData {
48+
file: GetPreSignedPostPayloadResponseDataFile!
49+
uploadId: String!
50+
parts: [MultiPartUploadFilePart!]!
51+
}
52+
53+
type CompleteMultiPartUploadResponse {
54+
data: Boolean
55+
error: FileError
56+
}
57+
3958
type GetPreSignedPostPayloadsResponse {
4059
error: FileError
4160
data: [GetPreSignedPostPayloadResponseData!]!
@@ -49,6 +68,28 @@ const plugin: GraphQLSchemaPlugin<FileManagerContext> = {
4968
data: [PreSignedPostPayloadInput]!
5069
): GetPreSignedPostPayloadsResponse
5170
}
71+
72+
type CreateMultiPartUploadResponse {
73+
data: CreateMultiPartUploadResponseData
74+
error: FileError
75+
}
76+
77+
input MultiPartUploadFilePartInput {
78+
partNumber: Int!
79+
etag: String!
80+
}
81+
82+
extend type FmMutation {
83+
createMultiPartUpload(
84+
data: PreSignedPostPayloadInput!
85+
numberOfParts: Number!
86+
): CreateMultiPartUploadResponse
87+
88+
completeMultiPartUpload(
89+
fileKey: String!
90+
uploadId: String!
91+
): CompleteMultiPartUploadResponse
92+
}
5293
`,
5394
resolvers: {
5495
FmQuery: {
@@ -104,6 +145,64 @@ const plugin: GraphQLSchemaPlugin<FileManagerContext> = {
104145
});
105146
}
106147
}
148+
},
149+
FmMutation: {
150+
createMultiPartUpload: async (_, args, context) => {
151+
await checkPermission(context, { rwd: "w" });
152+
153+
const s3Client = new S3({
154+
region: process.env.AWS_REGION,
155+
signatureVersion: "v4"
156+
});
157+
158+
try {
159+
const useCase = new CreateMultiPartUploadUseCase(
160+
String(process.env.S3_BUCKET),
161+
s3Client
162+
);
163+
164+
const multiPartUpload = await useCase.execute({
165+
file: args.data,
166+
numberOfParts: args.numberOfParts
167+
});
168+
169+
return new Response(multiPartUpload);
170+
} catch (e) {
171+
return new ErrorResponse({
172+
message: e.message,
173+
code: e.code,
174+
data: e.data
175+
});
176+
}
177+
},
178+
completeMultiPartUpload: async (_, args, context) => {
179+
await checkPermission(context, { rwd: "w" });
180+
181+
const s3Client = new S3({
182+
region: process.env.AWS_REGION,
183+
signatureVersion: "v4"
184+
});
185+
186+
try {
187+
const useCase = new CompleteMultiPartUploadUseCase(
188+
String(process.env.S3_BUCKET),
189+
s3Client
190+
);
191+
192+
await useCase.execute({
193+
fileKey: args.fileKey,
194+
uploadId: args.uploadId
195+
});
196+
197+
return new Response(true);
198+
} catch (e) {
199+
return new ErrorResponse({
200+
message: e.message,
201+
code: e.code,
202+
data: e.data
203+
});
204+
}
205+
}
107206
}
108207
}
109208
}

packages/api-file-manager-s3/src/types.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ export interface PresignedPostPayloadData {
99
keyPrefix?: string;
1010
}
1111

12+
export interface FileData {
13+
id: string;
14+
name: string;
15+
key: string;
16+
type: string;
17+
size: number;
18+
}
19+
1220
export interface PresignedPostPayloadDataResponse {
1321
data: S3.PresignedPost;
14-
file: {
15-
id: string;
16-
name: string;
17-
key: string;
18-
type: string;
19-
size: number;
20-
};
22+
file: FileData;
2123
}
Lines changed: 6 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
1-
// @ts-ignore `mdbid` has no type declarations
2-
import mdbid from "mdbid";
3-
import sanitizeFilename from "sanitize-filename";
41
import S3 from "aws-sdk/clients/s3";
52
import { validation } from "@webiny/validation";
6-
import { PresignedPostPayloadData, PresignedPostPayloadDataResponse } from "~/types";
73
import { FileManagerSettings } from "@webiny/api-file-manager/types";
8-
import { mimeTypes } from "./mimeTypes";
4+
import { prepareFileData } from "~/utils/prepareFileData";
5+
import { PresignedPostPayloadData, PresignedPostPayloadDataResponse } from "~/types";
96

107
const S3_BUCKET = process.env.S3_BUCKET;
118
const UPLOAD_MAX_FILE_SIZE_DEFAULT = 1099511627776; // 1TB
@@ -24,37 +21,7 @@ export const getPresignedPostPayload = (
2421
data: PresignedPostPayloadData,
2522
settings: FileManagerSettings
2623
): PresignedPostPayloadDataResponse => {
27-
// If type is missing, let's use the default "application/octet-stream" type,
28-
// which is also the default type that the Amazon S3 would use.
29-
if (!data.type) {
30-
data.type = "application/octet-stream";
31-
}
32-
33-
const contentType = data.type;
34-
if (!contentType) {
35-
throw Error(`File's content type could not be resolved.`);
36-
}
37-
38-
const id = data.id || mdbid();
39-
let key = data.key || sanitizeFilename(data.name);
40-
41-
// We must prefix file key with file ID.
42-
if (!key.startsWith(id)) {
43-
key = id + "/" + key;
44-
}
45-
46-
if (data.keyPrefix) {
47-
key = data.keyPrefix + key;
48-
}
49-
50-
// Replace all whitespace.
51-
key = key.replace(/\s/g, "");
52-
53-
// Make sure file key contains a file extension
54-
const extensions = mimeTypes[contentType];
55-
if (!extensions.some(ext => key.endsWith(`.${ext}`))) {
56-
key = key + `.${extensions[0]}`;
57-
}
24+
const file = prepareFileData(data);
5825

5926
const uploadMinFileSize = sanitizeFileSizeValue(settings.uploadMinFileSize, 0);
6027
const uploadMaxFileSize = sanitizeFileSizeValue(
@@ -67,8 +34,8 @@ export const getPresignedPostPayload = (
6734
Bucket: S3_BUCKET,
6835
Conditions: [["content-length-range", uploadMinFileSize, uploadMaxFileSize]],
6936
Fields: {
70-
"Content-Type": contentType,
71-
key
37+
"Content-Type": file.type,
38+
key: file.key
7239
}
7340
};
7441

@@ -81,12 +48,6 @@ export const getPresignedPostPayload = (
8148

8249
return {
8350
data: payload,
84-
file: {
85-
id,
86-
name: data.name,
87-
key,
88-
type: contentType,
89-
size: data.size
90-
}
51+
file
9152
};
9253
};

0 commit comments

Comments
 (0)