@balena/pinejs 20.1.0-build-add-large-file-uploads-interfaces-e645db6b429537d3cb2e40da3167bcfcc6b31b37-1 → 20.1.0-build-large-file-uploads-2-b83400458570d926f7a6abddeb7a149e89f52abe-2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pinejs-cache.json +1 -1
- package/.versionbot/CHANGELOG.yml +2950 -2
- package/CHANGELOG.md +1091 -1
- package/out/config-loader/env.d.ts +4 -0
- package/out/config-loader/env.js +4 -0
- package/out/config-loader/env.js.map +1 -1
- package/out/sbvr-api/abstract-sql.js +19 -9
- package/out/sbvr-api/abstract-sql.js.map +1 -1
- package/out/server-glue/module.js +2 -0
- package/out/server-glue/module.js.map +1 -1
- package/out/webresource-handler/index.d.ts +19 -2
- package/out/webresource-handler/index.js +53 -18
- package/out/webresource-handler/index.js.map +1 -1
- package/out/webresource-handler/multipartUpload.d.ts +17 -0
- package/out/webresource-handler/multipartUpload.js +259 -0
- package/out/webresource-handler/multipartUpload.js.map +1 -0
- package/out/webresource-handler/webresource.d.ts +42 -0
- package/out/webresource-handler/webresource.js +2 -0
- package/out/webresource-handler/webresource.js.map +1 -0
- package/out/webresource-handler/webresource.sbvr +60 -0
- package/package.json +8 -8
- package/src/config-loader/env.ts +11 -0
- package/src/sbvr-api/abstract-sql.ts +28 -9
- package/src/server-glue/module.ts +2 -0
- package/src/webresource-handler/index.ts +106 -31
- package/src/webresource-handler/multipartUpload.ts +371 -0
- package/src/webresource-handler/webresource.sbvr +60 -0
- package/src/webresource-handler/webresource.ts +48 -0
|
@@ -11,9 +11,16 @@ import {
|
|
|
11
11
|
odataNameToSqlName,
|
|
12
12
|
sqlNameToODataName,
|
|
13
13
|
} from '@balena/odata-to-abstract-sql';
|
|
14
|
+
import type { ConfigLoader } from '../server-glue/module.js';
|
|
14
15
|
import { errors, permissions } from '../server-glue/module.js';
|
|
15
16
|
import type { WebResourceType as WebResource } from '@balena/sbvr-types';
|
|
16
17
|
import { TypedError } from 'typed-error';
|
|
18
|
+
import type WebresourceModel from './webresource.js';
|
|
19
|
+
import { importSBVR } from '../server-glue/sbvr-loader.js';
|
|
20
|
+
import {
|
|
21
|
+
isMultipartUploadAvailable,
|
|
22
|
+
multipartUploadHooks,
|
|
23
|
+
} from './multipartUpload.js';
|
|
17
24
|
|
|
18
25
|
export * from './handlers/index.js';
|
|
19
26
|
|
|
@@ -56,17 +63,23 @@ export interface CommitMultipartUploadPayload {
|
|
|
56
63
|
providerCommitData?: Record<string, any>;
|
|
57
64
|
}
|
|
58
65
|
|
|
66
|
+
export interface CancelMultipartUploadPayload {
|
|
67
|
+
fileKey: string;
|
|
68
|
+
uploadId: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
59
71
|
export interface WebResourceHandler {
|
|
60
72
|
handleFile: (resource: IncomingFile) => Promise<UploadResponse>;
|
|
61
73
|
removeFile: (fileReference: string) => Promise<void>;
|
|
62
74
|
onPreRespond: (webResource: WebResource) => Promise<WebResource>;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
commitInfo: CommitMultipartUploadPayload
|
|
69
|
-
|
|
75
|
+
multipartUpload?: {
|
|
76
|
+
begin: (
|
|
77
|
+
fieldName: string,
|
|
78
|
+
payload: BeginMultipartUploadPayload,
|
|
79
|
+
) => Promise<BeginMultipartUploadHandlerResponse>;
|
|
80
|
+
commit: (commitInfo: CommitMultipartUploadPayload) => Promise<WebResource>;
|
|
81
|
+
cancel: (cancelInfo: CancelMultipartUploadPayload) => Promise<void>;
|
|
82
|
+
};
|
|
70
83
|
}
|
|
71
84
|
|
|
72
85
|
export class WebResourceError extends TypedError {}
|
|
@@ -96,30 +109,27 @@ export const setupWebresourceHandler = (handler: WebResourceHandler): void => {
|
|
|
96
109
|
export const getWebresourceHandler = (): WebResourceHandler | undefined => {
|
|
97
110
|
return configuredWebResourceHandler;
|
|
98
111
|
};
|
|
112
|
+
const notValidUpload = () => Promise.resolve(false);
|
|
99
113
|
|
|
100
|
-
const
|
|
101
|
-
fieldname: string,
|
|
114
|
+
const getRequestUploadValidator = async (
|
|
102
115
|
req: Express.Request,
|
|
103
|
-
|
|
116
|
+
odataRequest: uriParser.ParsedODataRequest,
|
|
117
|
+
): Promise<(fieldName: string) => Promise<boolean>> => {
|
|
104
118
|
if (req.method !== 'POST' && req.method !== 'PATCH') {
|
|
105
|
-
return
|
|
119
|
+
return notValidUpload;
|
|
106
120
|
}
|
|
107
121
|
|
|
108
122
|
const apiRoot = getApiRoot(req);
|
|
109
123
|
if (apiRoot == null) {
|
|
110
|
-
return
|
|
124
|
+
return notValidUpload;
|
|
111
125
|
}
|
|
112
126
|
const model = getModel(apiRoot);
|
|
113
|
-
const odataRequest = uriParser.parseOData({
|
|
114
|
-
url: req.url,
|
|
115
|
-
method: req.method,
|
|
116
|
-
});
|
|
117
127
|
const sqlResourceName = sbvrUtils.resolveSynonym(odataRequest);
|
|
118
128
|
|
|
119
129
|
const table = model.abstractSql.tables[sqlResourceName];
|
|
120
130
|
|
|
121
131
|
if (table == null) {
|
|
122
|
-
return
|
|
132
|
+
return notValidUpload;
|
|
123
133
|
}
|
|
124
134
|
|
|
125
135
|
const permission = req.method === 'POST' ? 'create' : 'update';
|
|
@@ -134,20 +144,22 @@ const isFileInValidPath = async (
|
|
|
134
144
|
);
|
|
135
145
|
|
|
136
146
|
if (!hasPermissions) {
|
|
137
|
-
return
|
|
147
|
+
return notValidUpload;
|
|
138
148
|
}
|
|
139
149
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
(
|
|
143
|
-
field
|
|
144
|
-
|
|
150
|
+
return async (fieldname: string) => {
|
|
151
|
+
const dbFieldName = odataNameToSqlName(fieldname);
|
|
152
|
+
return table.fields.some(
|
|
153
|
+
(field) =>
|
|
154
|
+
field.fieldName === dbFieldName && field.dataType === 'WebResource',
|
|
155
|
+
);
|
|
156
|
+
};
|
|
145
157
|
};
|
|
146
158
|
|
|
147
159
|
export const getUploaderMiddlware = (
|
|
148
160
|
handler: WebResourceHandler,
|
|
149
161
|
): Express.RequestHandler => {
|
|
150
|
-
return (req, res, next) => {
|
|
162
|
+
return async (req, res, next) => {
|
|
151
163
|
if (!req.is('multipart')) {
|
|
152
164
|
next();
|
|
153
165
|
return;
|
|
@@ -158,6 +170,20 @@ export const getUploaderMiddlware = (
|
|
|
158
170
|
const bb = busboy({ headers: req.headers });
|
|
159
171
|
let isAborting = false;
|
|
160
172
|
|
|
173
|
+
const parsedOdataRequest = uriParser.parseOData({
|
|
174
|
+
url: req.url,
|
|
175
|
+
method: req.method,
|
|
176
|
+
});
|
|
177
|
+
const webResourcesFieldNames = getWebResourceFields(
|
|
178
|
+
parsedOdataRequest,
|
|
179
|
+
false,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const isValidUpload = await getRequestUploadValidator(
|
|
183
|
+
req,
|
|
184
|
+
parsedOdataRequest,
|
|
185
|
+
);
|
|
186
|
+
|
|
161
187
|
const finishFileUpload = () => {
|
|
162
188
|
req.unpipe(bb);
|
|
163
189
|
req.on('readable', req.read.bind(req));
|
|
@@ -185,7 +211,7 @@ export const getUploaderMiddlware = (
|
|
|
185
211
|
completeUploads.push(
|
|
186
212
|
(async () => {
|
|
187
213
|
try {
|
|
188
|
-
if (!(await
|
|
214
|
+
if (!(await isValidUpload(fieldname))) {
|
|
189
215
|
filestream.resume();
|
|
190
216
|
return;
|
|
191
217
|
}
|
|
@@ -207,7 +233,10 @@ export const getUploaderMiddlware = (
|
|
|
207
233
|
uploadedFilePaths.push(result.filename);
|
|
208
234
|
} catch (err: any) {
|
|
209
235
|
filestream.resume();
|
|
210
|
-
|
|
236
|
+
bb.emit(
|
|
237
|
+
'error',
|
|
238
|
+
new errors.BadRequestError(err.message ?? 'Error uploading file'),
|
|
239
|
+
);
|
|
211
240
|
}
|
|
212
241
|
})(),
|
|
213
242
|
);
|
|
@@ -217,6 +246,14 @@ export const getUploaderMiddlware = (
|
|
|
217
246
|
// This receives the form fields and transforms them into a standard JSON body
|
|
218
247
|
// This is a similar behavior as previous multer library did
|
|
219
248
|
bb.on('field', (name, val) => {
|
|
249
|
+
if (webResourcesFieldNames.includes(name)) {
|
|
250
|
+
isAborting = true;
|
|
251
|
+
bb.emit(
|
|
252
|
+
'error',
|
|
253
|
+
new errors.BadRequestError('WebResource field must be a blob.'),
|
|
254
|
+
);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
220
257
|
req.body[name] = val;
|
|
221
258
|
});
|
|
222
259
|
|
|
@@ -242,7 +279,7 @@ export const getUploaderMiddlware = (
|
|
|
242
279
|
}
|
|
243
280
|
});
|
|
244
281
|
|
|
245
|
-
bb.on('error', async (err) => {
|
|
282
|
+
bb.on('error', async (err: Error) => {
|
|
246
283
|
finishFileUpload();
|
|
247
284
|
await clearFiles();
|
|
248
285
|
|
|
@@ -254,15 +291,17 @@ export const getUploaderMiddlware = (
|
|
|
254
291
|
);
|
|
255
292
|
}
|
|
256
293
|
|
|
257
|
-
|
|
258
|
-
|
|
294
|
+
if (!sbvrUtils.handleHttpErrors(req, res, err)) {
|
|
295
|
+
getLogger(getApiRoot(req)).error('Error uploading file', err);
|
|
296
|
+
next(err);
|
|
297
|
+
}
|
|
259
298
|
});
|
|
260
299
|
req.pipe(bb);
|
|
261
300
|
};
|
|
262
301
|
};
|
|
263
302
|
|
|
264
|
-
const getWebResourceFields = (
|
|
265
|
-
request: uriParser.
|
|
303
|
+
export const getWebResourceFields = (
|
|
304
|
+
request: uriParser.ParsedODataRequest,
|
|
266
305
|
useTranslations = true,
|
|
267
306
|
): string[] => {
|
|
268
307
|
// Translations will use modifyFields(translated) rather than fields(original) so we need to
|
|
@@ -294,6 +333,9 @@ const throwIfWebresourceNotInMultipart = (
|
|
|
294
333
|
{ req, request }: HookArgs,
|
|
295
334
|
) => {
|
|
296
335
|
if (
|
|
336
|
+
request.custom.isAction !== 'beginUpload' &&
|
|
337
|
+
request.custom.isAction !== 'commitUpload' &&
|
|
338
|
+
request.custom.isAction !== 'cancelUpload' &&
|
|
297
339
|
!req.is?.('multipart') &&
|
|
298
340
|
webResourceFields.some((field) => request.values[field] != null)
|
|
299
341
|
) {
|
|
@@ -490,4 +532,37 @@ export const setupUploadHooks = (
|
|
|
490
532
|
resourceName,
|
|
491
533
|
getCreateWebResourceHooks(handler),
|
|
492
534
|
);
|
|
535
|
+
|
|
536
|
+
if (isMultipartUploadAvailable(handler)) {
|
|
537
|
+
sbvrUtils.addPureHook(
|
|
538
|
+
'POST',
|
|
539
|
+
apiRoot,
|
|
540
|
+
resourceName,
|
|
541
|
+
multipartUploadHooks(handler),
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
const initSql = `
|
|
547
|
+
CREATE INDEX IF NOT EXISTS idx_multipart_upload_uuid ON "multipart upload" (uuid);
|
|
548
|
+
CREATE INDEX IF NOT EXISTS idx_multipart_upload_status ON "multipart upload" (status);
|
|
549
|
+
`;
|
|
550
|
+
|
|
551
|
+
const modelText = await importSBVR('./webresource.sbvr', import.meta);
|
|
552
|
+
|
|
553
|
+
declare module '../sbvr-api/sbvr-utils.js' {
|
|
554
|
+
export interface API {
|
|
555
|
+
webresource: PinejsClient<WebresourceModel>;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export const config: ConfigLoader.Config = {
|
|
560
|
+
models: [
|
|
561
|
+
{
|
|
562
|
+
modelName: 'webresource',
|
|
563
|
+
apiRoot: 'webresource',
|
|
564
|
+
modelText,
|
|
565
|
+
initSql,
|
|
566
|
+
},
|
|
567
|
+
],
|
|
493
568
|
};
|
|
@@ -0,0 +1,371 @@
|
|
|
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 './index.js';
|
|
9
|
+
import { getWebResourceFields } from './index.js';
|
|
10
|
+
import type { PinejsClient } from '../sbvr-api/sbvr-utils.js';
|
|
11
|
+
import { api } from '../sbvr-api/sbvr-utils.js';
|
|
12
|
+
import type { ODataRequest } from '../sbvr-api/uri-parser.js';
|
|
13
|
+
import { errors, sbvrUtils } from '../server-glue/module.js';
|
|
14
|
+
import { webResource as webResourceEnv } from '../config-loader/env.js';
|
|
15
|
+
import * as permissions from '../sbvr-api/permissions.js';
|
|
16
|
+
|
|
17
|
+
type BeginUploadDbCheck = BeginMultipartUploadPayload & WebResource;
|
|
18
|
+
|
|
19
|
+
export interface PendingUpload extends BeginMultipartUploadPayload {
|
|
20
|
+
fieldName: string;
|
|
21
|
+
fileKey: string;
|
|
22
|
+
uploadId: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface BeginUploadResponse {
|
|
26
|
+
[fieldName: string]: {
|
|
27
|
+
uuid: string;
|
|
28
|
+
uploadParts: UploadPart[];
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Is WebResourceHandler but with beginMultipartUpload and commitMultipartUpload as not optional
|
|
33
|
+
type MultipartUploadHandler = WebResourceHandler &
|
|
34
|
+
Required<Pick<WebResourceHandler, 'multipartUpload'>>;
|
|
35
|
+
|
|
36
|
+
const MB = 1024 * 1024;
|
|
37
|
+
|
|
38
|
+
export const isMultipartUploadAvailable = (
|
|
39
|
+
webResourceHandler: WebResourceHandler,
|
|
40
|
+
): webResourceHandler is MultipartUploadHandler => {
|
|
41
|
+
return (
|
|
42
|
+
webResourceEnv.multipartUploadEnabled &&
|
|
43
|
+
webResourceHandler.multipartUpload != null
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const multipartUploadHooks = (
|
|
48
|
+
webResourceHandler: MultipartUploadHandler,
|
|
49
|
+
): sbvrUtils.Hooks => {
|
|
50
|
+
return {
|
|
51
|
+
POSTPARSE: async ({ req, request, tx, api: applicationApi }) => {
|
|
52
|
+
if (request.odataQuery.property?.resource === 'beginUpload') {
|
|
53
|
+
const uploadParams = await validateBeginUpload(request, applicationApi);
|
|
54
|
+
|
|
55
|
+
// This transaction is necessary because beginUpload requests
|
|
56
|
+
// will rollback the transaction (in order to first validate)
|
|
57
|
+
// The metadata requested. If we don't pass any transaction
|
|
58
|
+
// It will use the default transaction handler which will error out
|
|
59
|
+
// on any rollback.
|
|
60
|
+
tx = await sbvrUtils.db.transaction();
|
|
61
|
+
req.tx = tx;
|
|
62
|
+
request.tx = tx;
|
|
63
|
+
|
|
64
|
+
request.method = 'PATCH';
|
|
65
|
+
request.values = uploadParams;
|
|
66
|
+
request.odataQuery.resource = request.resourceName;
|
|
67
|
+
delete request.odataQuery.property;
|
|
68
|
+
request.custom.isAction = 'beginUpload';
|
|
69
|
+
} else if (request.odataQuery.property?.resource === 'commitUpload') {
|
|
70
|
+
const commitPayload = await validateCommitUpload(
|
|
71
|
+
request,
|
|
72
|
+
applicationApi,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const webresource = await webResourceHandler.multipartUpload.commit({
|
|
76
|
+
fileKey: commitPayload.metadata.fileKey,
|
|
77
|
+
uploadId: commitPayload.metadata.uploadId,
|
|
78
|
+
filename: commitPayload.metadata.filename,
|
|
79
|
+
providerCommitData: commitPayload.providerCommitData,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await api.webresource.patch({
|
|
83
|
+
resource: 'multipart_upload',
|
|
84
|
+
body: {
|
|
85
|
+
status: 'completed',
|
|
86
|
+
},
|
|
87
|
+
options: {
|
|
88
|
+
$filter: {
|
|
89
|
+
uuid: commitPayload.uuid,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
passthrough: {
|
|
93
|
+
tx: tx,
|
|
94
|
+
req: permissions.root,
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
request.method = 'PATCH';
|
|
99
|
+
request.values = {
|
|
100
|
+
[commitPayload.metadata.fieldName]: webresource,
|
|
101
|
+
};
|
|
102
|
+
request.odataQuery.resource = request.resourceName;
|
|
103
|
+
delete request.odataQuery.property;
|
|
104
|
+
request.custom.isAction = 'commitUpload';
|
|
105
|
+
request.custom.commitUploadPayload = webresource;
|
|
106
|
+
} else if (request.odataQuery.property?.resource === 'cancelUpload') {
|
|
107
|
+
const { uuid, fileKey, uploadId } = await validateCancelPayload(
|
|
108
|
+
request,
|
|
109
|
+
applicationApi,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
await webResourceHandler.multipartUpload.cancel({ fileKey, uploadId });
|
|
113
|
+
|
|
114
|
+
await api.webresource.patch({
|
|
115
|
+
resource: 'multipart_upload',
|
|
116
|
+
body: {
|
|
117
|
+
status: 'cancelled',
|
|
118
|
+
},
|
|
119
|
+
options: {
|
|
120
|
+
$filter: { uuid },
|
|
121
|
+
},
|
|
122
|
+
passthrough: {
|
|
123
|
+
tx: tx,
|
|
124
|
+
req: permissions.root,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
request.method = 'GET';
|
|
129
|
+
request.odataQuery.resource = request.resourceName;
|
|
130
|
+
delete request.odataQuery.property;
|
|
131
|
+
request.custom.isAction = 'cancelUpload';
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
PRERESPOND: async ({ req, request, response, tx }) => {
|
|
135
|
+
if (request.custom.isAction === 'beginUpload') {
|
|
136
|
+
// In the case where the transaction has failed because it had invalid payload
|
|
137
|
+
// such as breaking a db constraint, this hook wouldn't have been called
|
|
138
|
+
// and would rather throw with the rule it failed to validate
|
|
139
|
+
// We rollback here as the patch was just a way to validate the upload payload
|
|
140
|
+
await tx.rollback();
|
|
141
|
+
|
|
142
|
+
response.statusCode = 200;
|
|
143
|
+
response.body = await beginUpload({
|
|
144
|
+
webResourceHandler,
|
|
145
|
+
odataRequest: request,
|
|
146
|
+
actorId: req.user?.actor,
|
|
147
|
+
});
|
|
148
|
+
} else if (request.custom.isAction === 'commitUpload') {
|
|
149
|
+
response.body = await webResourceHandler.onPreRespond(
|
|
150
|
+
request.custom.commitUploadPayload,
|
|
151
|
+
);
|
|
152
|
+
} else if (request.custom.isAction === 'cancelUpload') {
|
|
153
|
+
response.statusCode = 204;
|
|
154
|
+
delete response.body;
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const beginUpload = async ({
|
|
161
|
+
webResourceHandler,
|
|
162
|
+
odataRequest,
|
|
163
|
+
actorId,
|
|
164
|
+
}: {
|
|
165
|
+
webResourceHandler: MultipartUploadHandler;
|
|
166
|
+
odataRequest: ODataRequest;
|
|
167
|
+
actorId?: number;
|
|
168
|
+
}): Promise<BeginUploadResponse> => {
|
|
169
|
+
const payload = odataRequest.values as {
|
|
170
|
+
[x: string]: BeginMultipartUploadPayload;
|
|
171
|
+
};
|
|
172
|
+
const fieldName = Object.keys(payload)[0];
|
|
173
|
+
const metadata = payload[fieldName];
|
|
174
|
+
const { fileKey, uploadId, uploadParts } =
|
|
175
|
+
await webResourceHandler.multipartUpload.begin(fieldName, metadata);
|
|
176
|
+
const uuid = randomUUID();
|
|
177
|
+
|
|
178
|
+
return await sbvrUtils.db.transaction(async (tx) => {
|
|
179
|
+
try {
|
|
180
|
+
await api.webresource.post({
|
|
181
|
+
resource: 'multipart_upload',
|
|
182
|
+
body: {
|
|
183
|
+
uuid,
|
|
184
|
+
resource_name: odataRequest.resourceName,
|
|
185
|
+
field_name: fieldName,
|
|
186
|
+
resource_id: odataRequest.affectedIds?.[0],
|
|
187
|
+
upload_id: uploadId,
|
|
188
|
+
file_key: fileKey,
|
|
189
|
+
status: 'pending',
|
|
190
|
+
filename: metadata.filename,
|
|
191
|
+
content_type: metadata.content_type,
|
|
192
|
+
size: metadata.size,
|
|
193
|
+
chunk_size: metadata.chunk_size,
|
|
194
|
+
expiry_date: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days in ms
|
|
195
|
+
is_created_by__actor: actorId,
|
|
196
|
+
},
|
|
197
|
+
passthrough: {
|
|
198
|
+
req: permissions.root,
|
|
199
|
+
tx,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
return { [fieldName]: { uuid, uploadParts } };
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.error('failed to start multipart upload', err);
|
|
205
|
+
throw new errors.BadRequestError('Failed to start multipart upload');
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const validateBeginUpload = async (
|
|
211
|
+
request: ODataRequest,
|
|
212
|
+
applicationApi: PinejsClient,
|
|
213
|
+
) => {
|
|
214
|
+
if (request.odataQuery.key == null) {
|
|
215
|
+
throw new errors.BadRequestError();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await applicationApi.request({
|
|
219
|
+
method: 'POST',
|
|
220
|
+
url: request.url.substring(1).replace('beginUpload', 'canAccess'),
|
|
221
|
+
body: { method: 'PATCH' },
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const fieldNames = Object.keys(request.values);
|
|
225
|
+
if (fieldNames.length !== 1) {
|
|
226
|
+
throw new errors.BadRequestError(
|
|
227
|
+
'You can only get upload url for one field at a time',
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const [fieldName] = fieldNames;
|
|
232
|
+
const webResourceFields = getWebResourceFields(request, false);
|
|
233
|
+
if (!webResourceFields.includes(fieldName)) {
|
|
234
|
+
throw new errors.BadRequestError(
|
|
235
|
+
`You must provide a valid webresource field from: ${JSON.stringify(webResourceFields)}`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const beginUploadPayload = parseBeginUploadPayload(request.values[fieldName]);
|
|
240
|
+
if (beginUploadPayload == null) {
|
|
241
|
+
throw new errors.BadRequestError('Invalid file metadata');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const uploadMetadataCheck: BeginUploadDbCheck = {
|
|
245
|
+
...beginUploadPayload,
|
|
246
|
+
// This is "probe" request. We don't actually store anything on the application table yet
|
|
247
|
+
// We just avoid creating the application record if it would fail anyway for some other db constraint
|
|
248
|
+
href: 'metadata_check_probe',
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
return { [fieldName]: uploadMetadataCheck };
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const parseBeginUploadPayload = (
|
|
255
|
+
payload: AnyObject,
|
|
256
|
+
): BeginMultipartUploadPayload | null => {
|
|
257
|
+
if (typeof payload !== 'object') {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let { filename, content_type, size, chunk_size } = payload;
|
|
262
|
+
if (
|
|
263
|
+
typeof filename !== 'string' ||
|
|
264
|
+
typeof content_type !== 'string' ||
|
|
265
|
+
typeof size !== 'number' ||
|
|
266
|
+
(chunk_size != null && typeof chunk_size !== 'number') ||
|
|
267
|
+
(chunk_size != null && chunk_size < 5 * MB)
|
|
268
|
+
) {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (chunk_size == null) {
|
|
273
|
+
chunk_size = 5 * MB;
|
|
274
|
+
}
|
|
275
|
+
return { filename, content_type, size, chunk_size };
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const validateCommitUpload = async (
|
|
279
|
+
request: ODataRequest,
|
|
280
|
+
applicationApi: PinejsClient,
|
|
281
|
+
) => {
|
|
282
|
+
if (request.odataQuery.key == null) {
|
|
283
|
+
throw new errors.BadRequestError();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
await applicationApi.request({
|
|
287
|
+
method: 'POST',
|
|
288
|
+
url: request.url.substring(1).replace('commitUpload', 'canAccess'),
|
|
289
|
+
body: { method: 'PATCH' },
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const { uuid, providerCommitData } = request.values;
|
|
293
|
+
if (typeof uuid !== 'string') {
|
|
294
|
+
throw new errors.BadRequestError('Invalid uuid type');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const [multipartUpload] = await api.webresource.get({
|
|
298
|
+
resource: 'multipart_upload',
|
|
299
|
+
options: {
|
|
300
|
+
$select: ['id', 'file_key', 'upload_id', 'field_name', 'filename'],
|
|
301
|
+
$filter: {
|
|
302
|
+
uuid,
|
|
303
|
+
status: 'pending',
|
|
304
|
+
expiry_date: { $gt: { $now: {} } },
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
passthrough: {
|
|
308
|
+
tx: request.tx,
|
|
309
|
+
req: permissions.rootRead,
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
if (multipartUpload == null) {
|
|
314
|
+
throw new errors.BadRequestError(`Invalid upload for uuid ${uuid}`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const metadata = {
|
|
318
|
+
fileKey: multipartUpload.file_key,
|
|
319
|
+
uploadId: multipartUpload.upload_id,
|
|
320
|
+
filename: multipartUpload.filename,
|
|
321
|
+
fieldName: multipartUpload.field_name,
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
return { uuid, providerCommitData, metadata };
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const validateCancelPayload = async (
|
|
328
|
+
request: ODataRequest,
|
|
329
|
+
applicationApi: PinejsClient,
|
|
330
|
+
) => {
|
|
331
|
+
if (request.odataQuery.key == null) {
|
|
332
|
+
throw new errors.BadRequestError();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
await applicationApi.request({
|
|
336
|
+
method: 'POST',
|
|
337
|
+
url: request.url.substring(1).replace('cancelUpload', 'canAccess'),
|
|
338
|
+
body: { method: 'PATCH' },
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const { uuid } = request.values;
|
|
342
|
+
if (typeof uuid !== 'string') {
|
|
343
|
+
throw new errors.BadRequestError('Invalid uuid type');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const [multipartUpload] = await api.webresource.get({
|
|
347
|
+
resource: 'multipart_upload',
|
|
348
|
+
options: {
|
|
349
|
+
$select: ['id', 'file_key', 'upload_id'],
|
|
350
|
+
$filter: {
|
|
351
|
+
uuid,
|
|
352
|
+
status: 'pending',
|
|
353
|
+
expiry_date: { $gt: { $now: {} } },
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
passthrough: {
|
|
357
|
+
tx: request.tx,
|
|
358
|
+
req: permissions.rootRead,
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
if (multipartUpload == null) {
|
|
363
|
+
throw new errors.BadRequestError(`Invalid upload for uuid ${uuid}`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
uuid,
|
|
368
|
+
fileKey: multipartUpload.file_key,
|
|
369
|
+
uploadId: multipartUpload.upload_id,
|
|
370
|
+
};
|
|
371
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Vocabulary: webresource
|
|
2
|
+
|
|
3
|
+
Term: actor
|
|
4
|
+
Concept Type: Integer (Type)
|
|
5
|
+
Term: expiry date
|
|
6
|
+
Concept Type: Date Time (Type)
|
|
7
|
+
Term: uuid
|
|
8
|
+
Concept Type: Short Text (Type)
|
|
9
|
+
Term: resource name
|
|
10
|
+
Concept Type: Short Text (Type)
|
|
11
|
+
Term: field name
|
|
12
|
+
Concept Type: Short Text (Type)
|
|
13
|
+
Term: resource id
|
|
14
|
+
Concept Type: Integer (Type)
|
|
15
|
+
Term: upload id
|
|
16
|
+
Concept Type: Short Text (Type)
|
|
17
|
+
Term: file key
|
|
18
|
+
Concept Type: Short Text (Type)
|
|
19
|
+
Term: status
|
|
20
|
+
Concept Type: Short Text (Type)
|
|
21
|
+
Term: filename
|
|
22
|
+
Concept Type: Short Text (Type)
|
|
23
|
+
Term: content type
|
|
24
|
+
Concept Type: Short Text (Type)
|
|
25
|
+
Term: size
|
|
26
|
+
Concept Type: Integer (Type)
|
|
27
|
+
Term: chunk size
|
|
28
|
+
Concept Type: Integer (Type)
|
|
29
|
+
Term: valid until date
|
|
30
|
+
Concept Type: Date Time (Type)
|
|
31
|
+
|
|
32
|
+
Term: multipart upload
|
|
33
|
+
Fact type: multipart upload has uuid
|
|
34
|
+
Necessity: each multipart upload has exactly one uuid
|
|
35
|
+
Necessity: each uuid is of exactly one multipart upload
|
|
36
|
+
Fact type: multipart upload has resource name
|
|
37
|
+
Necessity: each multipart upload has exactly one resource name
|
|
38
|
+
Fact type: multipart upload has field name
|
|
39
|
+
Necessity: each multipart upload has exactly one field name
|
|
40
|
+
Fact type: multipart upload has resource id
|
|
41
|
+
Necessity: each multipart upload has exactly one resource id
|
|
42
|
+
Fact type: multipart upload has upload id
|
|
43
|
+
Necessity: each multipart upload has exactly one upload id
|
|
44
|
+
Fact type: multipart upload has file key
|
|
45
|
+
Necessity: each multipart upload has exactly one file key
|
|
46
|
+
Fact type: multipart upload has status
|
|
47
|
+
Necessity: each multipart upload has exactly one status
|
|
48
|
+
Definition: "pending" or "completed" or "cancelled"
|
|
49
|
+
Fact type: multipart upload has filename
|
|
50
|
+
Necessity: each multipart upload has exactly one filename
|
|
51
|
+
Fact type: multipart upload has content type
|
|
52
|
+
Necessity: each multipart upload has exactly one content type
|
|
53
|
+
Fact type: multipart upload has size
|
|
54
|
+
Necessity: each multipart upload has exactly one size
|
|
55
|
+
Fact type: multipart upload has chunk size
|
|
56
|
+
Necessity: each multipart upload has exactly one chunk size
|
|
57
|
+
Fact type: multipart upload has expiry date
|
|
58
|
+
Necessity: each multipart upload has exactly one expiry date
|
|
59
|
+
Fact type: multipart upload is created by actor
|
|
60
|
+
Necessity: each multipart upload is created by at most one actor
|