@balena/pinejs 15.1.0-build-web-resource-4-60a3313d5465335b98b5a93fdfa92d801e914f5a-4 → 15.1.0-build-upsert-6f9a458cc52c017ddaf48ceb7e4597522cdf4dfc-1

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