@balena/pinejs 15.0.2-build-renovate-faker-js-faker-8-x-80784e9ad7062c0f45e3d91ce8b9862a22bd8edb-1 → 15.1.0-build-web-resource-4-366a6aee494d7b10ba96d42af0b39b91c57220b3-1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (30) hide show
  1. package/.pinejs-cache.json +1 -1
  2. package/.versionbot/CHANGELOG.yml +17 -7
  3. package/CHANGELOG.md +4 -3
  4. package/README.md +10 -0
  5. package/VERSION +1 -1
  6. package/docker-compose.npm-test.yml +22 -1
  7. package/out/config-loader/config-loader.d.ts +2 -0
  8. package/out/config-loader/config-loader.js +13 -2
  9. package/out/config-loader/config-loader.js.map +1 -1
  10. package/out/sbvr-api/sbvr-utils.d.ts +6 -2
  11. package/out/sbvr-api/sbvr-utils.js +11 -2
  12. package/out/sbvr-api/sbvr-utils.js.map +1 -1
  13. package/out/server-glue/server.js +0 -2
  14. package/out/server-glue/server.js.map +1 -1
  15. package/out/server-glue/webresource-handler.d.ts +21 -0
  16. package/out/server-glue/webresource-handler.js +222 -0
  17. package/out/server-glue/webresource-handler.js.map +1 -0
  18. package/out/server-glue/webresource-handlers/NoopHandler.d.ts +5 -0
  19. package/out/server-glue/webresource-handlers/NoopHandler.js +17 -0
  20. package/out/server-glue/webresource-handlers/NoopHandler.js.map +1 -0
  21. package/out/server-glue/webresource-handlers/S3Handler.d.ts +12 -0
  22. package/out/server-glue/webresource-handlers/S3Handler.js +71 -0
  23. package/out/server-glue/webresource-handlers/S3Handler.js.map +1 -0
  24. package/package.json +18 -13
  25. package/src/config-loader/config-loader.ts +27 -2
  26. package/src/sbvr-api/sbvr-utils.ts +10 -1
  27. package/src/server-glue/server.ts +0 -3
  28. package/src/server-glue/webresource-handler.ts +327 -0
  29. package/src/server-glue/webresource-handlers/NoopHandler.ts +20 -0
  30. package/src/server-glue/webresource-handlers/S3Handler.ts +99 -0
@@ -0,0 +1,327 @@
1
+ import type * as Express from 'express';
2
+ import * as busboy from 'busboy';
3
+ import * as is from 'type-is';
4
+ import * as stream from 'stream';
5
+ import * as uriParser from '../sbvr-api/uri-parser';
6
+ import { getApiRoot, getModel } from '../sbvr-api/sbvr-utils';
7
+ import { checkPermissions } from '../sbvr-api/permissions';
8
+ import * as sbvrUtils from '../sbvr-api/sbvr-utils';
9
+ import { S3Handler } from './webresource-handlers/S3Handler';
10
+ import { NoopHandler } from './webresource-handlers/NoopHandler';
11
+ import {
12
+ odataNameToSqlName,
13
+ sqlNameToODataName,
14
+ } from '@balena/odata-to-abstract-sql';
15
+ import { errors, permissions } from './module';
16
+
17
+ export interface IncomingFile {
18
+ fieldname: string;
19
+ originalname: string;
20
+ encoding: string;
21
+ mimetype: string;
22
+ stream: stream.Readable;
23
+ }
24
+
25
+ export interface UploadResponse {
26
+ size: number;
27
+ filename: string;
28
+ }
29
+
30
+ export interface WebResourceHandler {
31
+ handleFile: (resource: IncomingFile) => Promise<UploadResponse>;
32
+ removeFile: (fileReference: string) => Promise<void>;
33
+ }
34
+
35
+ type WebResourcesDbResponse = {
36
+ [fieldname: string]: { href: string } | undefined | null;
37
+ };
38
+
39
+ const ifFileInValidPath = async (
40
+ fieldname: string,
41
+ req: Express.Request,
42
+ ): Promise<boolean> => {
43
+ if (req.method !== 'POST' && req.method !== 'PATCH') {
44
+ return false;
45
+ }
46
+
47
+ const apiRoot = getApiRoot(req);
48
+ if (apiRoot == null) {
49
+ return false;
50
+ }
51
+ const model = getModel(apiRoot);
52
+ const { resourceName } = await uriParser.parseOData({
53
+ url: req.url,
54
+ method: req.method,
55
+ });
56
+
57
+ const permission = req.method === 'POST' ? 'create' : 'update';
58
+ const vocab = model.versions[model.versions.length - 1];
59
+ const hasPermissions = await checkPermissions(
60
+ req,
61
+ permission,
62
+ resourceName,
63
+ vocab,
64
+ );
65
+
66
+ if (!hasPermissions) {
67
+ return false;
68
+ }
69
+
70
+ // TODO: This could be cached
71
+ const fields = model.abstractSql.tables[resourceName].fields;
72
+ const dbFieldName = odataNameToSqlName(fieldname);
73
+ for (const field of fields) {
74
+ if (field.fieldName === dbFieldName && field.dataType === 'WebResource') {
75
+ return true;
76
+ }
77
+ }
78
+
79
+ // TODO: We could do a pre-check if there is a SBVR rule specifying file max size
80
+ // This would avoid needing the roundtrip to DB and uploading a file just to remove it
81
+
82
+ return false;
83
+ };
84
+
85
+ export const getUploaderMiddlware = (
86
+ handler: WebResourceHandler,
87
+ ): Express.RequestHandler => {
88
+ return async (req, res, next) => {
89
+ if (!is(req, ['multipart'])) {
90
+ return next();
91
+ }
92
+ const filesUploaded: string[] = [];
93
+ const completeUploads: Array<Promise<void>> = [];
94
+
95
+ const bb = busboy({ headers: req.headers });
96
+ let isAborting = false;
97
+
98
+ const done = () => {
99
+ req.unpipe(bb);
100
+ req.on('readable', req.read.bind(req));
101
+ bb.removeAllListeners();
102
+ };
103
+
104
+ const clearFiles = () => {
105
+ isAborting = true;
106
+ const deletions = filesUploaded.map((file) => handler.removeFile(file));
107
+ // Best effort: We try to remove all uploaded files, but if this fails, there is not much to do
108
+ return Promise.all(deletions).catch((err) =>
109
+ console.error('Error deleting file', err),
110
+ );
111
+ };
112
+
113
+ bb.on('file', async (fieldname, filestream, info) => {
114
+ if (!isAborting && (await ifFileInValidPath(fieldname, req))) {
115
+ const file: IncomingFile = {
116
+ originalname: info.filename,
117
+ encoding: info.encoding,
118
+ mimetype: info.mimeType,
119
+ stream: filestream,
120
+ fieldname,
121
+ };
122
+ const promise = handler.handleFile(file).then((result) => {
123
+ req.body[fieldname] = {
124
+ filename: info.filename,
125
+ contentType: info.mimeType,
126
+ contentDisposition: undefined,
127
+ size: result.size,
128
+ href: result.filename,
129
+ };
130
+ filesUploaded.push(result.filename);
131
+ });
132
+ completeUploads.push(promise);
133
+ } else {
134
+ filestream.resume();
135
+ }
136
+ });
137
+
138
+ bb.on('field', (name, val, _info) => {
139
+ req.body[name] = val;
140
+ });
141
+
142
+ bb.on('finish', async () => {
143
+ try {
144
+ await Promise.all(completeUploads);
145
+ done();
146
+ next();
147
+ } catch (err: any) {
148
+ await clearFiles();
149
+
150
+ if (err?.message.includes('File size exceeded')) {
151
+ return sbvrUtils.handleHttpErrors(
152
+ req,
153
+ res,
154
+ new errors.BadRequestError('File size exceeded'),
155
+ );
156
+ }
157
+
158
+ console.error('Error uploading file', err);
159
+ next(err);
160
+ }
161
+ });
162
+
163
+ bb.on('error', async (err) => {
164
+ await clearFiles();
165
+ done();
166
+ next(err);
167
+ });
168
+ req.pipe(bb);
169
+ };
170
+ };
171
+
172
+ // TODO: this can be cached
173
+ const getWebResourceFields = (request: uriParser.ODataRequest): string[] => {
174
+ const fields =
175
+ request.abstractSqlModel?.tables[request.resourceName]?.modifyFields ??
176
+ request.abstractSqlModel?.tables[request.resourceName]?.fields;
177
+ if (fields == null) {
178
+ return [];
179
+ }
180
+
181
+ return fields
182
+ .filter((f) => f.dataType === 'WebResource')
183
+ .map((f) => sqlNameToODataName(f.fieldName));
184
+ };
185
+
186
+ const getModifiedFields = (request: uriParser.ODataRequest): string[] => {
187
+ return Object.entries(request.values)
188
+ .filter(([_key, value]) => value !== undefined)
189
+ .map(([key, _value]) => key);
190
+ };
191
+
192
+ const deleteFiles = async (
193
+ keysToDelete: string[],
194
+ webResourceHandler: WebResourceHandler,
195
+ ) => {
196
+ const promises = keysToDelete.map((r) => webResourceHandler.removeFile(r));
197
+ await Promise.all(promises);
198
+ };
199
+
200
+ const getCreateWebResourceHook = (webResourceHandler: WebResourceHandler) => {
201
+ return {
202
+ 'POSTRUN-ERROR': async ({ tx, request }) => {
203
+ tx?.on('rollback', async () => {
204
+ const fields = getWebResourceFields(request);
205
+
206
+ if (fields.length === 0) {
207
+ return;
208
+ }
209
+
210
+ const keysToDelete: string[] = fields
211
+ .filter((f) => isDefined(request.values[f]))
212
+ .map((f) => request.values[f].href);
213
+ await deleteFiles(keysToDelete, webResourceHandler);
214
+ });
215
+ },
216
+ } as sbvrUtils.Hooks;
217
+ };
218
+
219
+ const isDefined = <T>(x: T | undefined | null): x is T => x != null;
220
+
221
+ const getWebResourcesHrefs = (webResources?: WebResourcesDbResponse | null) => {
222
+ const hrefs = Object.values(webResources ?? {})
223
+ .filter(isDefined)
224
+ .map((resourceKey) => resourceKey.href);
225
+ return hrefs;
226
+ };
227
+
228
+ const getRemoveWebResourceHook = (webResourceHandler: WebResourceHandler) => {
229
+ return {
230
+ PRERUN: async (args) => {
231
+ const { api, request } = args;
232
+ let fields = getWebResourceFields(request);
233
+
234
+ if (fields.length === 0) {
235
+ return;
236
+ }
237
+
238
+ if (request.method === 'PATCH') {
239
+ if (request.odataQuery?.key == null) {
240
+ throw new errors.BadRequestError(
241
+ 'WebResources can only be updated when providing a resource key.',
242
+ );
243
+ }
244
+ const allFields = getModifiedFields(request);
245
+ fields = fields.filter((f) => allFields.includes(f));
246
+ }
247
+
248
+ if (fields.length === 0) {
249
+ return;
250
+ }
251
+
252
+ const [id, rest] = await sbvrUtils.getAffectedIds(args);
253
+ if (id == null) {
254
+ return;
255
+ }
256
+
257
+ if (rest != null) {
258
+ throw new errors.BadRequestError(
259
+ 'Resources containing webresources can only be updated/deleted one at a time.',
260
+ );
261
+ }
262
+
263
+ const webResources = (await api.get({
264
+ resource: request.resourceName,
265
+ passthrough: {
266
+ tx: args.tx,
267
+ req: permissions.root,
268
+ },
269
+ id,
270
+ options: {
271
+ $select: fields,
272
+ },
273
+ })) as WebResourcesDbResponse | undefined | null;
274
+
275
+ request.custom.$pineWebResourcesToDelete =
276
+ getWebResourcesHrefs(webResources);
277
+ },
278
+ POSTRUN: ({ tx, request }) => {
279
+ tx.on('end', () => {
280
+ const keysToDelete: string[] =
281
+ request.custom.$pineWebResourcesToDelete || [];
282
+ // on purpose does not await for this promise to resolve
283
+ deleteFiles(keysToDelete, webResourceHandler).catch((err) => {
284
+ console.error(`Failed to delete pending files`, err);
285
+ });
286
+ });
287
+ },
288
+ } as sbvrUtils.Hooks;
289
+ };
290
+
291
+ export const getDefaultHandler = (): WebResourceHandler => {
292
+ let handler: WebResourceHandler;
293
+ try {
294
+ handler = new S3Handler();
295
+ } catch (e) {
296
+ console.warn(`Failed to initialize S3 handler, using noop ${e}`);
297
+ handler = new NoopHandler();
298
+ }
299
+ return handler;
300
+ };
301
+
302
+ export const setupUploadHooks = (
303
+ handler: WebResourceHandler,
304
+ apiRoot: string,
305
+ resourceName: string,
306
+ ) => {
307
+ sbvrUtils.addPureHook(
308
+ 'PATCH',
309
+ apiRoot,
310
+ resourceName,
311
+ getRemoveWebResourceHook(handler),
312
+ );
313
+
314
+ sbvrUtils.addPureHook(
315
+ 'DELETE',
316
+ apiRoot,
317
+ resourceName,
318
+ getRemoveWebResourceHook(handler),
319
+ );
320
+
321
+ sbvrUtils.addPureHook(
322
+ 'POST',
323
+ apiRoot,
324
+ resourceName,
325
+ getCreateWebResourceHook(handler),
326
+ );
327
+ };
@@ -0,0 +1,20 @@
1
+ import {
2
+ IncomingFile,
3
+ UploadResponse,
4
+ WebResourceHandler,
5
+ } from '../webresource-handler';
6
+
7
+ export class NoopHandler implements WebResourceHandler {
8
+ public async handleFile(resource: IncomingFile): Promise<UploadResponse> {
9
+ // handleFile must consume the file stream
10
+ resource.stream.resume();
11
+ return {
12
+ filename: 'noop',
13
+ size: 0,
14
+ };
15
+ }
16
+
17
+ public async removeFile(_fileReference: string): Promise<void> {
18
+ return;
19
+ }
20
+ }
@@ -0,0 +1,99 @@
1
+ import { optionalVar, requiredVar, intVar } from '@balena/env-parsing';
2
+ import {
3
+ IncomingFile,
4
+ UploadResponse,
5
+ WebResourceHandler,
6
+ } from '../webresource-handler';
7
+ import {
8
+ S3Client,
9
+ S3ClientConfig,
10
+ DeleteObjectCommand,
11
+ PutObjectCommandInput,
12
+ } from '@aws-sdk/client-s3';
13
+ import { Upload } from '@aws-sdk/lib-storage';
14
+
15
+ import { randomUUID } from 'crypto';
16
+
17
+ export class S3Handler implements WebResourceHandler {
18
+ private readonly config: S3ClientConfig;
19
+ private readonly bucket: string;
20
+ private readonly endpoint: string;
21
+ private readonly maxFileSize = intVar(
22
+ 'PINEJS_WEBRESOURCE_MAXFILESIZE',
23
+ 52428800,
24
+ );
25
+ private client: S3Client;
26
+
27
+ constructor() {
28
+ this.endpoint = requiredVar('S3_ENDPOINT');
29
+ this.config = {
30
+ region: optionalVar('S3_REGION', 'us-east-1'),
31
+ credentials: {
32
+ accessKeyId: requiredVar('S3_ACCESS_KEY'),
33
+ secretAccessKey: requiredVar('S3_SECRET_KEY'),
34
+ },
35
+ endpoint: this.endpoint,
36
+ forcePathStyle: true,
37
+ };
38
+
39
+ this.bucket = optionalVar(
40
+ 'S3_STORAGE_ADAPTER_BUCKET',
41
+ 'balena-pine-web-resources',
42
+ );
43
+ this.client = new S3Client(this.config);
44
+ }
45
+
46
+ public async handleFile(resource: IncomingFile): Promise<UploadResponse> {
47
+ let size = 0;
48
+ const key = `${resource.fieldname}_${randomUUID()}_${
49
+ resource.originalname
50
+ }`;
51
+ const params: PutObjectCommandInput = {
52
+ Bucket: this.bucket,
53
+ ACL: 'public-read',
54
+ StorageClass: 'STANDARD',
55
+ Key: key,
56
+ Body: resource.stream,
57
+ ContentType: resource.mimetype,
58
+ };
59
+ const upload = new Upload({ client: this.client, params });
60
+
61
+ upload.on('httpUploadProgress', async (ev) => {
62
+ size = ev.total ? ev.total : ev.loaded!;
63
+ if (size > this.maxFileSize) {
64
+ await upload.abort();
65
+ }
66
+ });
67
+
68
+ try {
69
+ await upload.done();
70
+ } catch (err: any) {
71
+ resource.stream.resume();
72
+ if (size > this.maxFileSize) {
73
+ throw new Error('File size exceeded');
74
+ }
75
+ throw err;
76
+ }
77
+
78
+ const filename = this.getS3URL(key);
79
+ return { size, filename };
80
+ }
81
+
82
+ public async removeFile(fileReference: string): Promise<void> {
83
+ const fileReferences = fileReference.split('/');
84
+ const fileKey = fileReferences[fileReferences.length - 1];
85
+
86
+ const command = new DeleteObjectCommand({
87
+ Bucket: this.bucket,
88
+ Key: fileKey,
89
+ });
90
+
91
+ await this.client.send(command);
92
+ }
93
+
94
+ private getS3URL(key: string) {
95
+ return this.endpoint.includes(this.bucket)
96
+ ? `${this.endpoint}/${key}`
97
+ : `${this.endpoint}/${this.bucket}/${key}`;
98
+ }
99
+ }