@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.
- package/.pinejs-cache.json +1 -1
- package/.versionbot/CHANGELOG.yml +24 -1
- package/CHANGELOG.md +7 -1
- package/README.md +10 -0
- package/VERSION +1 -1
- package/docker-compose.npm-test.yml +22 -1
- package/out/config-loader/config-loader.d.ts +2 -0
- package/out/config-loader/config-loader.js +13 -2
- package/out/config-loader/config-loader.js.map +1 -1
- package/out/sbvr-api/sbvr-utils.d.ts +6 -2
- package/out/sbvr-api/sbvr-utils.js +11 -2
- package/out/sbvr-api/sbvr-utils.js.map +1 -1
- package/out/server-glue/server.js +0 -2
- package/out/server-glue/server.js.map +1 -1
- package/out/server-glue/webresource-handler.d.ts +21 -0
- package/out/server-glue/webresource-handler.js +217 -0
- package/out/server-glue/webresource-handler.js.map +1 -0
- package/out/server-glue/webresource-handlers/NoopHandler.d.ts +5 -0
- package/out/server-glue/webresource-handlers/NoopHandler.js +17 -0
- package/out/server-glue/webresource-handlers/NoopHandler.js.map +1 -0
- package/out/server-glue/webresource-handlers/S3Handler.d.ts +11 -0
- package/out/server-glue/webresource-handlers/S3Handler.js +58 -0
- package/out/server-glue/webresource-handlers/S3Handler.js.map +1 -0
- package/package.json +17 -12
- package/src/config-loader/config-loader.ts +27 -2
- package/src/sbvr-api/sbvr-utils.ts +10 -1
- package/src/server-glue/server.ts +0 -3
- package/src/server-glue/webresource-handler.ts +317 -0
- package/src/server-glue/webresource-handlers/NoopHandler.ts +20 -0
- 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
|
+
}
|