@balena/pinejs 15.0.1 → 15.1.0-build-web-resource-4-528904929ba5aa3ec2cf3e80bd7800775c7b60a3-2

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