@balena/pinejs 17.0.0-build-v17-4b8f0faa9617d64057e94cdc0ca92650925c1d39-1 → 17.0.0-build-large-file-uploads-293e65ee371a69130834c50fef2f7f42cc18133f-1

Sign up to get free protection for your applications and to get access to all the features.
@@ -13,6 +13,8 @@ import {
13
13
  } from '@balena/odata-to-abstract-sql';
14
14
  import { errors, permissions } from '../server-glue/module';
15
15
  import type { WebResourceType as WebResource } from '@balena/sbvr-types';
16
+ import type { AnyObject } from 'pinejs-client-core';
17
+ import { multipartUploadHooks } from './multipartUpload';
16
18
 
17
19
  export * from './handlers';
18
20
 
@@ -29,10 +31,44 @@ export interface UploadResponse {
29
31
  filename: string;
30
32
  }
31
33
 
34
+ export interface BeginMultipartUploadPayload {
35
+ filename: string;
36
+ content_type: string;
37
+ size: number;
38
+ chunk_size: number;
39
+ }
40
+
41
+ export interface UploadPart {
42
+ url: string;
43
+ chunkSize: number;
44
+ partNumber: number;
45
+ }
46
+
47
+ export interface BeginMultipartUploadHandlerResponse {
48
+ uploadParts: UploadPart[];
49
+ fileKey: string;
50
+ uploadId: string;
51
+ }
52
+
53
+ export interface CommitMultipartUploadPayload {
54
+ fileKey: string;
55
+ uploadId: string;
56
+ filename: string;
57
+ providerCommitData?: AnyObject;
58
+ }
59
+
32
60
  export interface WebResourceHandler {
33
61
  handleFile: (resource: IncomingFile) => Promise<UploadResponse>;
34
62
  removeFile: (fileReference: string) => Promise<void>;
35
63
  onPreRespond: (webResource: WebResource) => Promise<WebResource>;
64
+
65
+ beginMultipartUpload: (
66
+ fieldName: string,
67
+ payload: BeginMultipartUploadPayload,
68
+ ) => Promise<BeginMultipartUploadHandlerResponse>;
69
+ commitMultipartUpload: (
70
+ commitInfo: CommitMultipartUploadPayload,
71
+ ) => Promise<WebResource>;
36
72
  }
37
73
 
38
74
  type WebResourcesDbResponse = {
@@ -201,7 +237,7 @@ export const getUploaderMiddlware = (
201
237
  };
202
238
  };
203
239
 
204
- const getWebResourceFields = (
240
+ export const getWebResourceFields = (
205
241
  request: uriParser.ODataRequest,
206
242
  useTranslations = true,
207
243
  ): string[] => {
@@ -234,6 +270,8 @@ const throwIfWebresourceNotInMultipart = (
234
270
  { req, request }: HookArgs,
235
271
  ) => {
236
272
  if (
273
+ request.custom.isAction !== 'beginUpload' &&
274
+ request.custom.isAction !== 'commitUpload' &&
237
275
  !req.is?.('multipart') &&
238
276
  webResourceFields.some((field) => request.values[field] != null)
239
277
  ) {
@@ -432,4 +470,23 @@ export const setupUploadHooks = (
432
470
  resourceName,
433
471
  getCreateWebResourceHooks(handler),
434
472
  );
473
+
474
+ sbvrUtils.addPureHook(
475
+ 'POST',
476
+ apiRoot,
477
+ resourceName,
478
+ multipartUploadHooks(handler),
479
+ );
480
+ };
481
+
482
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
483
+ const webresourceModel: string = require('./webresource.sbvr');
484
+ export const config = {
485
+ models: [
486
+ {
487
+ apiRoot: 'webresource',
488
+ modelText: webresourceModel,
489
+ modelName: 'webresource',
490
+ },
491
+ ] as sbvrUtils.ExecutableModel[],
435
492
  };
@@ -0,0 +1,275 @@
1
+ import type { WebResourceType as WebResource } from '@balena/sbvr-types';
2
+ import { randomUUID } from 'node:crypto';
3
+ import type { AnyObject } from 'pinejs-client-core';
4
+ import type {
5
+ BeginMultipartUploadPayload,
6
+ UploadPart,
7
+ WebResourceHandler,
8
+ } from '.';
9
+ import { getWebResourceFields } from '.';
10
+ import type { PinejsClient } from '../sbvr-api/sbvr-utils';
11
+ import { api } from '../sbvr-api/sbvr-utils';
12
+ import type { ODataRequest } from '../sbvr-api/uri-parser';
13
+ import { errors, sbvrUtils } from '../server-glue/module';
14
+
15
+ type BeginUploadDbCheck = BeginMultipartUploadPayload & WebResource;
16
+
17
+ export interface PendingUpload extends BeginMultipartUploadPayload {
18
+ fieldName: string;
19
+ fileKey: string;
20
+ uploadId: string;
21
+ }
22
+
23
+ export interface BeginUploadResponse {
24
+ [fieldName: string]: {
25
+ uuid: string;
26
+ uploadParts: UploadPart[];
27
+ };
28
+ }
29
+
30
+ const MB = 1024 * 1024;
31
+
32
+ export const multipartUploadHooks = (
33
+ webResourceHandler: WebResourceHandler,
34
+ ): sbvrUtils.Hooks => {
35
+ return {
36
+ POSTPARSE: async ({ req, request, tx, api: applicationApi }) => {
37
+ if (request.odataQuery.property?.resource === 'beginUpload') {
38
+ const uploadParams = await validateBeginUpload(request, applicationApi);
39
+
40
+ // This transaction is necessary because beginUpload requests
41
+ // will rollback the transaction (in order to first validate)
42
+ // The metadata requested. If we don't pass any transaction
43
+ // It will use the default transaction handler which will error out
44
+ // on any rollback.
45
+ tx = await sbvrUtils.db.transaction();
46
+ req.tx = tx;
47
+ request.tx = tx;
48
+
49
+ request.method = 'PATCH';
50
+ request.values = uploadParams;
51
+ request.odataQuery.resource = request.resourceName;
52
+ delete request.odataQuery.property;
53
+ request.custom.isAction = 'beginUpload';
54
+ } else if (request.odataQuery.property?.resource === 'commitUpload') {
55
+ const commitPayload = await validateCommitUpload(
56
+ request,
57
+ applicationApi,
58
+ );
59
+
60
+ const webresource = await webResourceHandler.commitMultipartUpload({
61
+ fileKey: commitPayload.metadata.fileKey,
62
+ uploadId: commitPayload.metadata.uploadId,
63
+ filename: commitPayload.metadata.filename,
64
+ providerCommitData: commitPayload.providerCommitData,
65
+ });
66
+
67
+ await api.webresource.patch({
68
+ resource: 'multipart_upload',
69
+ body: {
70
+ status: 'completed',
71
+ },
72
+ options: {
73
+ $filter: {
74
+ uuid: commitPayload.uuid,
75
+ },
76
+ },
77
+ passthrough: {
78
+ tx: tx,
79
+ },
80
+ });
81
+
82
+ request.method = 'PATCH';
83
+ request.values = {
84
+ [commitPayload.metadata.fieldName]: webresource,
85
+ };
86
+ request.odataQuery.resource = request.resourceName;
87
+ delete request.odataQuery.property;
88
+ request.custom.isAction = 'commitUpload';
89
+ request.custom.commitUploadPayload = webresource;
90
+ }
91
+ },
92
+ PRERESPOND: async ({ req, request, response, tx }) => {
93
+ if (request.custom.isAction === 'beginUpload') {
94
+ // In the case where the transaction has failed because it had invalid payload
95
+ // such as breaking a db constraint, this hook wouldn't have been called
96
+ // and would rather throw with the rule it failed to validate
97
+ // We rollback here as the patch was just a way to validate the upload payload
98
+ await tx.rollback();
99
+
100
+ response.statusCode = 200;
101
+ response.body = await beginUpload(
102
+ webResourceHandler,
103
+ request,
104
+ req.user?.actor,
105
+ );
106
+ } else if (request.custom.isAction === 'commitUpload') {
107
+ response.body = await webResourceHandler.onPreRespond(
108
+ request.custom.commitUploadPayload,
109
+ );
110
+ }
111
+ },
112
+ };
113
+ };
114
+
115
+ export const beginUpload = async (
116
+ webResourceHandler: WebResourceHandler,
117
+ odataRequest: ODataRequest,
118
+ actorId?: number,
119
+ ): Promise<BeginUploadResponse> => {
120
+ const payload = odataRequest.values as {
121
+ [x: string]: BeginMultipartUploadPayload;
122
+ };
123
+ const fieldName = Object.keys(payload)[0];
124
+ const metadata = payload[fieldName];
125
+
126
+ const { fileKey, uploadId, uploadParts } =
127
+ await webResourceHandler.beginMultipartUpload(fieldName, metadata);
128
+ const uuid = randomUUID();
129
+
130
+ try {
131
+ await api.webresource.post({
132
+ resource: 'multipart_upload',
133
+ body: {
134
+ uuid,
135
+ resource_name: odataRequest.resourceName,
136
+ field_name: fieldName,
137
+ resource_id: odataRequest.affectedIds?.[0],
138
+ upload_id: uploadId,
139
+ file_key: fileKey,
140
+ status: 'pending',
141
+ filename: metadata.filename,
142
+ content_type: metadata.content_type,
143
+ size: metadata.size,
144
+ chunk_size: metadata.chunk_size,
145
+ expiry_date: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days in ms
146
+ is_created_by__actor: actorId,
147
+ },
148
+ });
149
+ } catch (err) {
150
+ console.error('failed to start multipart upload', err);
151
+ throw new errors.BadRequestError('Failed to start multipart upload');
152
+ }
153
+
154
+ return { [fieldName]: { uuid, uploadParts } };
155
+ };
156
+
157
+ const validateBeginUpload = async (
158
+ request: ODataRequest,
159
+ applicationApi: PinejsClient,
160
+ ) => {
161
+ if (request.odataQuery.key == null) {
162
+ throw new errors.BadRequestError();
163
+ }
164
+
165
+ await applicationApi.post({
166
+ url: request.url.substring(1).replace('beginUpload', 'canAccess'),
167
+ body: { method: 'PATCH' },
168
+ });
169
+
170
+ const fieldNames = Object.keys(request.values);
171
+ if (fieldNames.length !== 1) {
172
+ throw new errors.BadRequestError(
173
+ 'You can only get upload url for one field at a time',
174
+ );
175
+ }
176
+
177
+ const [fieldName] = fieldNames;
178
+ const webResourceFields = getWebResourceFields(request, false);
179
+ if (!webResourceFields.includes(fieldName)) {
180
+ throw new errors.BadRequestError(
181
+ `You must provide a valid webresource field from: ${JSON.stringify(webResourceFields)}`,
182
+ );
183
+ }
184
+
185
+ const beginUploadPayload = parseBeginUploadPayload(request.values[fieldName]);
186
+ if (beginUploadPayload == null) {
187
+ throw new errors.BadRequestError('Invalid file metadata');
188
+ }
189
+
190
+ const uploadMetadataCheck: BeginUploadDbCheck = {
191
+ ...beginUploadPayload,
192
+ href: 'metadata_check',
193
+ };
194
+
195
+ return { [fieldName]: uploadMetadataCheck };
196
+ };
197
+
198
+ const parseBeginUploadPayload = (
199
+ payload: AnyObject,
200
+ ): BeginMultipartUploadPayload | null => {
201
+ if (typeof payload !== 'object') {
202
+ return null;
203
+ }
204
+
205
+ let { filename, content_type, size, chunk_size } = payload;
206
+ if (
207
+ typeof filename !== 'string' ||
208
+ typeof content_type !== 'string' ||
209
+ typeof size !== 'number' ||
210
+ (chunk_size != null && typeof chunk_size !== 'number') ||
211
+ (chunk_size != null && chunk_size < 5 * MB)
212
+ ) {
213
+ return null;
214
+ }
215
+
216
+ if (chunk_size == null) {
217
+ chunk_size = 5 * MB;
218
+ }
219
+ return { filename, content_type, size, chunk_size };
220
+ };
221
+
222
+ const validateCommitUpload = async (
223
+ request: ODataRequest,
224
+ applicationApi: PinejsClient,
225
+ ) => {
226
+ if (request.odataQuery.key == null) {
227
+ throw new errors.BadRequestError();
228
+ }
229
+
230
+ await applicationApi.post({
231
+ url: request.url.substring(1).replace('commitUpload', 'canAccess'),
232
+ body: { method: 'PATCH' },
233
+ });
234
+
235
+ const { uuid, providerCommitData } = request.values;
236
+ if (typeof uuid !== 'string') {
237
+ throw new errors.BadRequestError('Invalid uuid type');
238
+ }
239
+
240
+ const [multipartUpload] = (await api.webresource.get({
241
+ resource: 'multipart_upload',
242
+ options: {
243
+ $select: ['id', 'file_key', 'upload_id', 'field_name', 'filename'],
244
+ $filter: {
245
+ uuid,
246
+ status: 'pending',
247
+ expiry_date: { $gt: { $now: {} } },
248
+ },
249
+ },
250
+ passthrough: {
251
+ tx: request.tx,
252
+ },
253
+ })) as [
254
+ {
255
+ id: number;
256
+ file_key: string;
257
+ upload_id: string;
258
+ field_name: string;
259
+ filename: string;
260
+ }?,
261
+ ];
262
+
263
+ if (multipartUpload == null) {
264
+ throw new errors.BadRequestError(`Invalid upload for uuid ${uuid}`);
265
+ }
266
+
267
+ const metadata = {
268
+ fileKey: multipartUpload.file_key,
269
+ uploadId: multipartUpload.upload_id,
270
+ filename: multipartUpload.filename,
271
+ fieldName: multipartUpload.field_name,
272
+ };
273
+
274
+ return { uuid, providerCommitData, metadata };
275
+ };
@@ -0,0 +1,63 @@
1
+ Vocabulary: Auth
2
+
3
+ Term: actor
4
+ Term: expiry date
5
+ Concept Type: Date Time (Type)
6
+
7
+ Vocabulary: webresource
8
+
9
+ Term: uuid
10
+ Concept Type: Short Text (Type)
11
+ Term: resource name
12
+ Concept Type: Short Text (Type)
13
+ Term: field name
14
+ Concept Type: Short Text (Type)
15
+ Term: resource id
16
+ Concept Type: Integer (Type)
17
+ Term: upload id
18
+ Concept Type: Short Text (Type)
19
+ Term: file key
20
+ Concept Type: Short Text (Type)
21
+ Term: status
22
+ Concept Type: Short Text (Type)
23
+ Term: filename
24
+ Concept Type: Short Text (Type)
25
+ Term: content type
26
+ Concept Type: Short Text (Type)
27
+ Term: size
28
+ Concept Type: Integer (Type)
29
+ Term: chunk size
30
+ Concept Type: Integer (Type)
31
+ Term: valid until date
32
+ Concept Type: Date Time (Type)
33
+
34
+ Term: multipart upload
35
+ Fact type: multipart upload has uuid
36
+ Necessity: each multipart upload has exactly one uuid
37
+ Necessity: each uuid is of exactly one multipart upload
38
+ Fact type: multipart upload has resource name
39
+ Necessity: each multipart upload has exactly one resource name
40
+ Fact type: multipart upload has field name
41
+ Necessity: each multipart upload has exactly one field name
42
+ Fact type: multipart upload has resource id
43
+ Necessity: each multipart upload has exactly one resource id
44
+ Fact type: multipart upload has upload id
45
+ Necessity: each multipart upload has exactly one upload id
46
+ Fact type: multipart upload has file key
47
+ Necessity: each multipart upload has exactly one file key
48
+ Fact type: multipart upload has status
49
+ Necessity: each multipart upload has exactly one status
50
+ Definition: "pending" or "completed" or "cancelled"
51
+ Fact type: multipart upload has filename
52
+ Necessity: each multipart upload has exactly one filename
53
+ Fact type: multipart upload has content type
54
+ Necessity: each multipart upload has exactly one content type
55
+ Fact type: multipart upload has size
56
+ Necessity: each multipart upload has exactly one size
57
+ Fact type: multipart upload has chunk size
58
+ Necessity: each multipart upload has exactly one chunk size
59
+ Fact type: multipart upload has expiry date (Auth)
60
+ Necessity: each multipart upload has exactly one expiry date (Auth)
61
+ Fact type: multipart upload is created by actor (Auth)
62
+ Necessity: each multipart upload is created by at most one actor (Auth)
63
+ Reference Type: informative