@balena/pinejs 15.0.1 → 15.1.0-build-web-resource-4-528904929ba5aa3ec2cf3e80bd7800775c7b60a3-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.
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
+ }