Skip to content

Commit 5b0208c

Browse files
fix(file-utils): remove direct export of clamav to avoid direct dep (#2247)
* fix(file-utils): remove direct export of clamav to avoid direct dep 0 * fix(file-utils): better schema handling for multipart request - * feat(file-utils): better stream handling GH-0 * fix(file-utils): refactor code to reduce cognitive complexity GH-0 * fix(file-utils): add a test case GH-0 * fix(file-utils): add test for multiple validator chain GH-0 * fix(file-utils): remove file on error from validators GH-0 * fix(file-utils): refactor to reduce complexity GH-0 * fix(ci-cd): refactor a bit more GH-0
1 parent ff2f0f4 commit 5b0208c

File tree

15 files changed

+261
-84
lines changed

15 files changed

+261
-84
lines changed

packages/file-utils/src/__tests__/integration/file-uploader.integration.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {DataObject} from '@loopback/repository';
22
import {Client, expect, sinon} from '@loopback/testlab';
33
import {FileUtilBindings} from '../..';
4+
import {MultipartModel} from './fixtures';
45
import {ParentWithFile} from './fixtures/controllers/parent.controller';
56
import {MulterConfigService} from './fixtures/services/multer-config.service';
67
import {TestApp} from './fixtures/test.application';
@@ -10,7 +11,10 @@ describe('FileUploaderComponent', () => {
1011
let app: TestApp;
1112
let client: Client;
1213
let receiverStub: {
13-
receive: sinon.SinonStub<[DataObject<ParentWithFile>], void>;
14+
receive: sinon.SinonStub<
15+
[DataObject<ParentWithFile & MultipartModel>],
16+
void
17+
>;
1418
};
1519
before('setupApplication', async () => {
1620
({app, client} = await setupApplication());
@@ -255,7 +259,35 @@ describe('FileUploaderComponent', () => {
255259
)
256260
.field('name', 'testName')
257261
.expect(400);
258-
expect(response.body.error.message).to.equal('Infected file: test.txt');
262+
expect(response.body.error.message).to.equal(
263+
'File is infected - test.txt',
264+
);
265+
});
266+
it('should parse a multipart request and apply all the validators', async () => {
267+
const response = await client
268+
.post('/multiple')
269+
.attach(
270+
'files',
271+
'./src/__tests__/integration/fixtures/dummy-files/test.txt',
272+
)
273+
.field('name', 'testName')
274+
.expect(400);
275+
expect(response.body.error.message).to.equal(
276+
'File is infected - test.txt',
277+
);
278+
279+
await client
280+
.post('/multiple')
281+
.attach(
282+
'files',
283+
'./src/__tests__/integration/fixtures/dummy-files/test.png',
284+
)
285+
.field('name', 'testName')
286+
.expect(204);
287+
288+
expect(
289+
receiverStub.receive.getCalls()[0].args[0].files?.size,
290+
).deepEqual(1916);
259291
});
260292
});
261293
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {inject} from '@loopback/core';
2+
import {getModelSchemaRef, post, response} from '@loopback/rest';
3+
import {multipartRequestBody} from '../../../../decorators';
4+
import {MultipartModel} from '../models';
5+
6+
export class MultipartController {
7+
constructor(
8+
@inject('services.ReceiverStub')
9+
private readonly receiverStub: {
10+
receive: (data: MultipartModel) => void;
11+
},
12+
) {}
13+
@post(`/multiple`)
14+
@response(200, {
15+
description: 'Multipart model instance',
16+
content: {'application/json': {schema: getModelSchemaRef(MultipartModel)}},
17+
})
18+
async createMultiple(
19+
@multipartRequestBody(MultipartModel)
20+
data: MultipartModel,
21+
): Promise<void> {
22+
this.receiverStub.receive(data);
23+
}
24+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './multipart.model';
12
export * from './multiple-files.model';
23
export * from './parent-with-config.model';
34
export * from './parent.model';
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import {Entity, model} from '@loopback/repository';
2+
import {fileProperty} from '../../../../decorators/file-property.decorator';
3+
import {FileTypeValidator} from '../../../../services';
4+
import {ClamAVValidator} from '../../../../sub-packages';
5+
6+
@model()
7+
export class MultipartModel extends Entity {
8+
@fileProperty({
9+
type: 'array',
10+
itemType: 'string',
11+
validators: [FileTypeValidator, ClamAVValidator],
12+
extensions: ['.pdf', '.txt', '.png'],
13+
})
14+
files: Express.Multer.File;
15+
16+
constructor(data?: Partial<MultipartModel>) {
17+
super(data);
18+
}
19+
}

packages/file-utils/src/__tests__/integration/fixtures/test.application.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {RestApplication} from '@loopback/rest';
55
import {ServiceMixin} from '@loopback/service-proxy';
66
import {AWSS3Bindings} from 'loopback4-s3';
77
import {FileUtilComponent} from '../../../component';
8-
import {ClamAVValidator} from '../../../services';
8+
import {ClamAVValidator} from '../../../sub-packages';
99
import {Parent} from './models';
1010

1111
export class TestApp extends BootMixin(

packages/file-utils/src/constant.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {ReferenceObject, SchemaObject} from '@loopback/rest';
12
import multer from 'multer';
23
import {
34
IFileRequestMetadata,
@@ -35,3 +36,13 @@ export function getUploadConfig(
3536
? (uploadOptions as IFileRequestMetadata<never>)
3637
: metadata;
3738
}
39+
40+
export function isSchemaObject(
41+
ob: SchemaObject | ReferenceObject | undefined,
42+
): ob is SchemaObject {
43+
return (ob as SchemaObject)?.type !== undefined;
44+
}
45+
46+
export function isFile(file: Partial<File>) {
47+
return file instanceof File;
48+
}

packages/file-utils/src/decorators/file-property.decorator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@ import {IFileRequestMetadata} from '../types';
1111
export function fileProperty<T>(
1212
definition: Partial<PropertyDefinition & IFileRequestMetadata<T>>,
1313
) {
14-
return property(definition);
14+
return property({...definition, file: true});
1515
}

packages/file-utils/src/decorators/multipart-request-body.decorator.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,30 @@
11
import {inject, ParameterDecoratorFactory} from '@loopback/core';
2-
import {getJsonSchema, requestBody, SchemaObject} from '@loopback/rest';
2+
import {
3+
getJsonSchema,
4+
ReferenceObject,
5+
requestBody,
6+
SchemaObject,
7+
} from '@loopback/rest';
38
import {cloneDeep} from 'lodash';
49

510
import {AnyObject, Entity} from '@loopback/repository';
611
import {ModelConstructor} from '@sourceloop/core';
12+
import {isSchemaObject} from '../constant';
713
import {FileUtilBindings} from '../keys';
814
import {IModelWithFileMetadata} from '../types';
15+
16+
function updateSchema(fieldSchema: SchemaObject | ReferenceObject | undefined) {
17+
if (isSchemaObject(fieldSchema)) {
18+
if (fieldSchema.type === 'array') {
19+
fieldSchema.items = {
20+
...fieldSchema.items,
21+
format: 'binary',
22+
};
23+
} else {
24+
fieldSchema.format = 'binary';
25+
}
26+
}
27+
}
928
/**
1029
* Decorator for handling multipart/form-data requests with file uploads and JSON data.
1130
*
@@ -49,12 +68,7 @@ export function multipartRequestBody<S extends Entity, T = AnyObject>(
4968
return function (target: object, member: string, index: number) {
5069
const defaultSchema: SchemaObject = {
5170
type: 'object',
52-
properties: {
53-
file: {
54-
type: 'string',
55-
format: 'binary',
56-
},
57-
},
71+
properties: {},
5872
};
5973
const schema = getJsonSchema(model) as SchemaObject;
6074
const fileFields = Object.entries(model.definition.properties)
@@ -63,12 +77,11 @@ export function multipartRequestBody<S extends Entity, T = AnyObject>(
6377
if (schema && fileFields.length) {
6478
const newSchema = cloneDeep(schema);
6579
for (const field of fileFields) {
66-
if (newSchema.properties?.[field]) {
67-
(newSchema.properties?.[field] as SchemaObject).format = 'binary';
68-
}
80+
const fieldSchema = newSchema.properties?.[field];
81+
updateSchema(fieldSchema);
6982
}
7083
defaultSchema.properties = {
71-
...defaultSchema.properties,
84+
...schema.properties,
7285
...newSchema.properties,
7386
};
7487
}

packages/file-utils/src/services/file-validator.provider.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import {Getter, extensionPoint, extensions, inject} from '@loopback/core';
1+
import {extensionPoint, extensions, Getter, inject} from '@loopback/core';
22
import {FileUtilBindings, FileValidatorExtensionPoint} from '../keys';
33
import {
44
FileValidatorWithConstructor,
55
getConfigProperty,
66
IFileRequestMetadata,
77
ParsedMultipartData,
8+
ValidationResult,
89
} from '../types';
910

1011
@extensionPoint(FileValidatorExtensionPoint.key)
@@ -17,7 +18,7 @@ export class FileValidatorService {
1718
) {}
1819
async validateParsedData(
1920
parsed: ParsedMultipartData,
20-
): Promise<Express.Multer.File | undefined> {
21+
): Promise<ValidationResult | undefined> {
2122
const {file} = parsed;
2223
if (!file) {
2324
return file;
@@ -31,9 +32,17 @@ export class FileValidatorService {
3132
applicable.includes(validator.constructor),
3233
);
3334
}
35+
const waiters: Promise<string | null>[] = [];
3436
for (const validator of filteredValidators) {
35-
newFile = await validator.validate(newFile);
37+
const result = await validator.validate(newFile);
38+
newFile = result.file;
39+
if (result.waiter) {
40+
waiters.push(result.waiter);
41+
}
3642
}
37-
return newFile;
43+
return {
44+
file: newFile,
45+
waiters,
46+
};
3847
}
3948
}

packages/file-utils/src/services/multer-storage.provider.ts

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
import {Context, Provider, inject, service} from '@loopback/core';
1+
import {Context, inject, Provider, service} from '@loopback/core';
2+
import {HttpErrors, Request} from '@loopback/rest';
23
import multer from 'multer';
3-
import {getConfigProperty, IFileRequestMetadata} from '../types';
44
import {FileUtilBindings} from '../keys';
5-
import {Request} from '@loopback/rest';
5+
import {
6+
getConfigProperty,
7+
IFileRequestMetadata,
8+
ValidationResult,
9+
} from '../types';
610
import {FileValidatorService} from './file-validator.provider';
711

812
export class MulterStorageProvider implements Provider<multer.StorageEngine> {
@@ -28,8 +32,19 @@ export class MulterStorageProvider implements Provider<multer.StorageEngine> {
2832
file,
2933
body: req.body,
3034
})
31-
.then(newFile => {
32-
if (newFile) storage._handleFile(req, newFile, cb);
35+
.then(result => {
36+
if (result) {
37+
storage._handleFile(req, result.file, (error, info) => {
38+
this._handleValidationResults(
39+
result,
40+
info,
41+
storage,
42+
req,
43+
cb,
44+
error,
45+
);
46+
});
47+
}
3348
})
3449
.catch(err => cb(err));
3550
},
@@ -50,4 +65,39 @@ export class MulterStorageProvider implements Provider<multer.StorageEngine> {
5065
`services.${config?.storageClass.name ?? 'MulterMemoryStorage'}`,
5166
);
5267
}
68+
69+
private _handleValidationResults(
70+
result: ValidationResult,
71+
parsedFile: Partial<Express.Multer.File> | undefined,
72+
storage: multer.StorageEngine,
73+
req: Request,
74+
cb: (error?: Error, info?: Partial<Express.Multer.File>) => void,
75+
error: Error | undefined,
76+
) {
77+
if (error) {
78+
cb(error);
79+
} else {
80+
Promise.all(result.waiters)
81+
.then(results => {
82+
const firstError = results.find(r => r);
83+
if (firstError) {
84+
if (parsedFile) {
85+
// need to do this casting because of wrong typings
86+
storage._removeFile(
87+
req,
88+
parsedFile as Express.Multer.File,
89+
err => {
90+
cb(new HttpErrors.BadRequest(firstError));
91+
},
92+
);
93+
} else {
94+
cb(new HttpErrors.BadRequest(firstError));
95+
}
96+
} else {
97+
cb(undefined, parsedFile);
98+
}
99+
})
100+
.catch(cb);
101+
}
102+
}
53103
}

0 commit comments

Comments
 (0)