@travetto/web-upload 6.0.0-rc.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/README.md +58 -0
- package/__index__.ts +5 -0
- package/package.json +49 -0
- package/src/config.ts +29 -0
- package/src/decorator.ts +68 -0
- package/src/interceptor.ts +54 -0
- package/src/trv.d.ts +7 -0
- package/src/types.ts +4 -0
- package/src/util.ts +191 -0
- package/support/fixtures/asset.yml +2 -0
- package/support/fixtures/empty +0 -0
- package/support/fixtures/empty.m4a +0 -0
- package/support/fixtures/logo +0 -0
- package/support/fixtures/logo.gif +0 -0
- package/support/fixtures/logo.png +0 -0
- package/support/fixtures/small-audio +0 -0
- package/support/fixtures/small-audio.mp3 +0 -0
- package/support/test/server.ts +181 -0
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<!-- This file was generated by @travetto/doc and should not be modified directly -->
|
|
2
|
+
<!-- Please modify https://github.com/travetto/travetto/tree/main/module/web-upload/DOC.tsx and execute "npx trv doc" to rebuild -->
|
|
3
|
+
# Web Upload Support
|
|
4
|
+
|
|
5
|
+
## Provides integration between the travetto asset and web module.
|
|
6
|
+
|
|
7
|
+
**Install: @travetto/web-upload**
|
|
8
|
+
```bash
|
|
9
|
+
npm install @travetto/web-upload
|
|
10
|
+
|
|
11
|
+
# or
|
|
12
|
+
|
|
13
|
+
yarn add @travetto/web-upload
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
This module provides a clean and direct mechanism for processing uploads, built upon [@fastify/busboy](https://github.com/fastify/busboy). The module also provides some best practices with respect to temporary file management.
|
|
17
|
+
|
|
18
|
+
Once the files are uploaded, they are exposed via [Endpoint](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#L14) parameters using the [@Upload](https://github.com/travetto/travetto/tree/main/module/web-upload/src/decorator.ts#L20) decorator. This decorator requires the related field type to be a standard [Buffer](https://nodejs.org/api/buffer.html#class-file) object, or a [FileMap](https://github.com/travetto/travetto/tree/main/module/web-upload/src/types.ts#L4).
|
|
19
|
+
|
|
20
|
+
A simple example:
|
|
21
|
+
|
|
22
|
+
**Code: Web controller with upload support**
|
|
23
|
+
```typescript
|
|
24
|
+
import { Controller, Post, Get } from '@travetto/web';
|
|
25
|
+
import { FileMap, Upload } from '@travetto/web-upload';
|
|
26
|
+
|
|
27
|
+
@Controller('/simple')
|
|
28
|
+
export class Simple {
|
|
29
|
+
|
|
30
|
+
@Get('/age')
|
|
31
|
+
getAge() {
|
|
32
|
+
return { age: 50 };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@Post('/age')
|
|
36
|
+
getPage() {
|
|
37
|
+
return { age: 20 };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param file A file to upload
|
|
42
|
+
*/
|
|
43
|
+
@Post('/file')
|
|
44
|
+
loadFile(@Upload() upload: File) {
|
|
45
|
+
return upload;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param uploads A map of files that were uploaded
|
|
50
|
+
*/
|
|
51
|
+
@Post('/files')
|
|
52
|
+
async loadFiles(@Upload() uploads: FileMap) {
|
|
53
|
+
for (const [, upload] of Object.entries(uploads)) {
|
|
54
|
+
return upload;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
package/__index__.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@travetto/web-upload",
|
|
3
|
+
"version": "6.0.0-rc.2",
|
|
4
|
+
"description": "Provides integration between the travetto asset and web module.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"web",
|
|
7
|
+
"travetto",
|
|
8
|
+
"typescript",
|
|
9
|
+
"upload"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://travetto.io",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": {
|
|
14
|
+
"email": "travetto.framework@gmail.com",
|
|
15
|
+
"name": "Travetto Framework"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"__index__.ts",
|
|
19
|
+
"src",
|
|
20
|
+
"support"
|
|
21
|
+
],
|
|
22
|
+
"main": "__index__.ts",
|
|
23
|
+
"repository": {
|
|
24
|
+
"url": "git+https://github.com/travetto/travetto.git",
|
|
25
|
+
"directory": "module/web-upload"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@fastify/busboy": "^3.1.1",
|
|
29
|
+
"@travetto/config": "^6.0.0-rc.2",
|
|
30
|
+
"@travetto/web": "^6.0.0-rc.2",
|
|
31
|
+
"file-type": "^20.4.1",
|
|
32
|
+
"mime": "^4.0.7"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@travetto/test": "^6.0.0-rc.2"
|
|
36
|
+
},
|
|
37
|
+
"peerDependenciesMeta": {
|
|
38
|
+
"@travetto/test": {
|
|
39
|
+
"optional": true
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"travetto": {
|
|
43
|
+
"displayName": "Web Upload Support"
|
|
44
|
+
},
|
|
45
|
+
"private": false,
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Config } from '@travetto/config';
|
|
2
|
+
import { Ignore } from '@travetto/schema';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Config for uploading within @travetto/web
|
|
6
|
+
*/
|
|
7
|
+
@Config('web.upload')
|
|
8
|
+
export class WebUploadConfig {
|
|
9
|
+
applies = false;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Max file size in bytes
|
|
13
|
+
*/
|
|
14
|
+
maxSize = 10 * 1024 * 1024;
|
|
15
|
+
/**
|
|
16
|
+
* List of types to allow/exclude
|
|
17
|
+
*/
|
|
18
|
+
types: string[] = [];
|
|
19
|
+
/**
|
|
20
|
+
* Cleanup temporary files after request finishes
|
|
21
|
+
*/
|
|
22
|
+
cleanupFiles: boolean = true;
|
|
23
|
+
|
|
24
|
+
@Ignore()
|
|
25
|
+
uploads?: Record<string, Partial<WebUploadConfig>>;
|
|
26
|
+
|
|
27
|
+
@Ignore()
|
|
28
|
+
matcher: (contentType: string) => boolean;
|
|
29
|
+
}
|
package/src/decorator.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { AppError, toConcrete, ClassInstance } from '@travetto/runtime';
|
|
2
|
+
import { ControllerRegistry, EndpointParamConfig, Param } from '@travetto/web';
|
|
3
|
+
import { SchemaRegistry } from '@travetto/schema';
|
|
4
|
+
|
|
5
|
+
import { WebUploadInterceptor } from './interceptor.ts';
|
|
6
|
+
import { WebUploadConfig } from './config.ts';
|
|
7
|
+
import { FileMap } from './types.ts';
|
|
8
|
+
import { WebUploadUtil } from './util.ts';
|
|
9
|
+
|
|
10
|
+
type UploadConfig = Partial<Pick<WebUploadConfig, 'types' | 'maxSize' | 'cleanupFiles'>>;
|
|
11
|
+
|
|
12
|
+
const FileMapContract = toConcrete<FileMap>();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Allows for supporting uploads
|
|
16
|
+
*
|
|
17
|
+
* @augments `@travetto/web-upload:Upload`
|
|
18
|
+
* @augments `@travetto/web:Param`
|
|
19
|
+
*/
|
|
20
|
+
export function Upload(
|
|
21
|
+
param: string | Partial<EndpointParamConfig> & UploadConfig = {},
|
|
22
|
+
): (inst: ClassInstance, prop: string, idx: number) => void {
|
|
23
|
+
|
|
24
|
+
if (typeof param === 'string') {
|
|
25
|
+
param = { name: param };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const finalConf = { ...param };
|
|
29
|
+
|
|
30
|
+
return (inst: ClassInstance, prop: string, idx: number): void => {
|
|
31
|
+
// Register field
|
|
32
|
+
ControllerRegistry.registerEndpointInterceptorConfig(
|
|
33
|
+
inst.constructor, inst[prop], WebUploadInterceptor,
|
|
34
|
+
{
|
|
35
|
+
applies: true,
|
|
36
|
+
maxSize: finalConf.maxSize,
|
|
37
|
+
types: finalConf.types,
|
|
38
|
+
cleanupFiles: finalConf.cleanupFiles,
|
|
39
|
+
uploads: {
|
|
40
|
+
[finalConf.name ?? prop]: {
|
|
41
|
+
maxSize: finalConf.maxSize,
|
|
42
|
+
types: finalConf.types,
|
|
43
|
+
cleanupFiles: finalConf.cleanupFiles
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return Param('body', {
|
|
50
|
+
...finalConf,
|
|
51
|
+
extract: (request, config) => {
|
|
52
|
+
const field = SchemaRegistry.getMethodSchema(inst.constructor, prop)[idx];
|
|
53
|
+
|
|
54
|
+
if (!field) {
|
|
55
|
+
throw new AppError(`Unknown field type, ensure you are using ${Blob.name}, ${File.name} or ${FileMapContract.name}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!(field.type === Blob || field.type === File || field.type === FileMapContract)) {
|
|
59
|
+
throw new AppError(`Cannot use upload decorator with ${field.type.name}, but only an ${Blob.name}, ${File.name} or ${FileMapContract.name}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const isMap = field.type === FileMapContract;
|
|
63
|
+
const map = WebUploadUtil.getRequestUploads(request);
|
|
64
|
+
return isMap ? map : map[config.name!];
|
|
65
|
+
}
|
|
66
|
+
})(inst, prop, idx);
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Inject, Injectable } from '@travetto/di';
|
|
2
|
+
import {
|
|
3
|
+
BodyParseInterceptor, WebInterceptor, WebInterceptorCategory, WebChainedContext,
|
|
4
|
+
WebResponse, DecompressInterceptor, WebInterceptorContext
|
|
5
|
+
} from '@travetto/web';
|
|
6
|
+
|
|
7
|
+
import { WebUploadConfig } from './config.ts';
|
|
8
|
+
import { WebUploadUtil } from './util.ts';
|
|
9
|
+
import { FileMap } from './types.ts';
|
|
10
|
+
|
|
11
|
+
@Injectable()
|
|
12
|
+
export class WebUploadInterceptor implements WebInterceptor<WebUploadConfig> {
|
|
13
|
+
|
|
14
|
+
category: WebInterceptorCategory = 'request';
|
|
15
|
+
runsBefore = [BodyParseInterceptor];
|
|
16
|
+
dependsOn = [DecompressInterceptor];
|
|
17
|
+
|
|
18
|
+
@Inject()
|
|
19
|
+
config: WebUploadConfig;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Produces final config object
|
|
23
|
+
*/
|
|
24
|
+
finalizeConfig({ config: base }: WebInterceptorContext<WebUploadConfig>, inputs: Partial<WebUploadConfig>[]): WebUploadConfig {
|
|
25
|
+
base.uploads ??= {};
|
|
26
|
+
// Override the uploads object with all the data from the inputs
|
|
27
|
+
for (const [k, cfg] of inputs.flatMap(el => Object.entries(el.uploads ?? {}))) {
|
|
28
|
+
Object.assign(base.uploads[k] ??= {}, cfg);
|
|
29
|
+
}
|
|
30
|
+
return base;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
applies({ config }: WebInterceptorContext<WebUploadConfig>): boolean {
|
|
34
|
+
return config.applies;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async filter({ request, config, next }: WebChainedContext<WebUploadConfig>): Promise<WebResponse> {
|
|
38
|
+
const uploads: FileMap = {};
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
for await (const item of WebUploadUtil.getUploads(request, config)) {
|
|
42
|
+
uploads[item.field] = await WebUploadUtil.toFile(item, config.uploads?.[item.field] ?? config);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
WebUploadUtil.setRequestUploads(request, uploads);
|
|
46
|
+
|
|
47
|
+
return await next();
|
|
48
|
+
} finally {
|
|
49
|
+
for (const [field, item] of Object.entries(uploads)) {
|
|
50
|
+
await WebUploadUtil.finishUpload(item, config.uploads?.[field] ?? config);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/trv.d.ts
ADDED
package/src/types.ts
ADDED
package/src/util.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import { Readable, Transform } from 'node:stream';
|
|
7
|
+
|
|
8
|
+
import busboy from '@fastify/busboy';
|
|
9
|
+
|
|
10
|
+
import { WebRequest, MimeUtil, WebBodyUtil } from '@travetto/web';
|
|
11
|
+
import { AsyncQueue, AppError, castTo, Util, BinaryUtil } from '@travetto/runtime';
|
|
12
|
+
|
|
13
|
+
import { WebUploadConfig } from './config.ts';
|
|
14
|
+
import { FileMap } from './types.ts';
|
|
15
|
+
|
|
16
|
+
const MULTIPART = new Set(['application/x-www-form-urlencoded', 'multipart/form-data']);
|
|
17
|
+
|
|
18
|
+
type UploadItem = { stream: Readable, filename?: string, field: string };
|
|
19
|
+
type FileType = { ext: string, mime: string };
|
|
20
|
+
const RawFileSymbol = Symbol();
|
|
21
|
+
const WebUploadSymbol = Symbol();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Web upload utilities
|
|
25
|
+
*/
|
|
26
|
+
export class WebUploadUtil {
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Write limiter
|
|
30
|
+
* @returns
|
|
31
|
+
*/
|
|
32
|
+
static limitWrite(maxSize: number, field?: string): Transform {
|
|
33
|
+
let read = 0;
|
|
34
|
+
return new Transform({
|
|
35
|
+
transform(chunk, encoding, callback): void {
|
|
36
|
+
read += (Buffer.isBuffer(chunk) || typeof chunk === 'string') ? chunk.length : (chunk instanceof Uint8Array ? chunk.byteLength : 0);
|
|
37
|
+
if (read > maxSize) {
|
|
38
|
+
callback(new AppError('File size exceeded', { category: 'data', details: { read, size: maxSize, field } }));
|
|
39
|
+
} else {
|
|
40
|
+
callback(null, chunk);
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get uploaded file path location
|
|
48
|
+
*/
|
|
49
|
+
static getUploadLocation(file: File): string {
|
|
50
|
+
return castTo<{ [RawFileSymbol]: string }>(file)[RawFileSymbol];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get all the uploads, separating multipart from direct
|
|
55
|
+
*/
|
|
56
|
+
static async* getUploads(request: WebRequest, config: Partial<WebUploadConfig>): AsyncIterable<UploadItem> {
|
|
57
|
+
if (!WebBodyUtil.isRaw(request.body)) {
|
|
58
|
+
throw new AppError('No input stream provided for upload', { category: 'data' });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const bodyStream = Buffer.isBuffer(request.body) ? Readable.from(request.body) : request.body;
|
|
62
|
+
request.body = undefined;
|
|
63
|
+
|
|
64
|
+
if (MULTIPART.has(request.headers.getContentType()?.full!)) {
|
|
65
|
+
const fileMaxes = Object.values(config.uploads ?? {}).map(x => x.maxSize).filter(x => x !== undefined);
|
|
66
|
+
const largestMax = fileMaxes.length ? Math.max(...fileMaxes) : config.maxSize;
|
|
67
|
+
const itr = new AsyncQueue<UploadItem>();
|
|
68
|
+
|
|
69
|
+
// Upload
|
|
70
|
+
bodyStream.pipe(busboy({
|
|
71
|
+
headers: {
|
|
72
|
+
'content-type': request.headers.get('Content-Type')!,
|
|
73
|
+
'content-disposition': request.headers.get('Content-Disposition')!,
|
|
74
|
+
'content-length': request.headers.get('Content-Length')!,
|
|
75
|
+
'content-range': request.headers.get('Content-Range')!,
|
|
76
|
+
'content-encoding': request.headers.get('Content-Encoding')!,
|
|
77
|
+
'content-transfer-encoding': request.headers.get('Content-Transfer-Encoding')!,
|
|
78
|
+
},
|
|
79
|
+
limits: { fileSize: largestMax }
|
|
80
|
+
})
|
|
81
|
+
.on('file', (field, stream, filename) => itr.add({ stream, filename, field }))
|
|
82
|
+
.on('limit', field => itr.throw(new AppError(`File size exceeded for ${field}`, { category: 'data' })))
|
|
83
|
+
.on('finish', () => itr.close())
|
|
84
|
+
.on('error', (err) => itr.throw(err instanceof Error ? err : new Error(`${err}`))));
|
|
85
|
+
|
|
86
|
+
yield* itr;
|
|
87
|
+
} else {
|
|
88
|
+
yield { stream: bodyStream, filename: request.headers.getFilename(), field: 'file' };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Convert an UploadItem to a File
|
|
94
|
+
*/
|
|
95
|
+
static async toFile({ stream, filename, field }: UploadItem, config: Partial<WebUploadConfig>): Promise<File> {
|
|
96
|
+
const uniqueDir = path.resolve(os.tmpdir(), `file_${Date.now()}_${Util.uuid(5)}`);
|
|
97
|
+
await fs.mkdir(uniqueDir, { recursive: true });
|
|
98
|
+
|
|
99
|
+
filename = filename ? path.basename(filename) : `unknown_${Date.now()}`;
|
|
100
|
+
|
|
101
|
+
const location = path.resolve(uniqueDir, filename);
|
|
102
|
+
const remove = (): Promise<void> => fs.rm(location).catch(() => { });
|
|
103
|
+
const mimeCheck = config.matcher ??= MimeUtil.matcher(config.types);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const target = createWriteStream(location);
|
|
107
|
+
|
|
108
|
+
await (config.maxSize ?
|
|
109
|
+
pipeline(stream, this.limitWrite(config.maxSize, field), target) :
|
|
110
|
+
pipeline(stream, target));
|
|
111
|
+
|
|
112
|
+
const detected = await this.getFileType(location);
|
|
113
|
+
|
|
114
|
+
if (!mimeCheck(detected.mime)) {
|
|
115
|
+
throw new AppError(`Content type not allowed: ${detected.mime}`, { category: 'data' });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!path.extname(filename)) {
|
|
119
|
+
filename = `${filename}.${detected.ext}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const file = BinaryUtil.readableBlob(() => createReadStream(location), {
|
|
123
|
+
contentType: detected.mime,
|
|
124
|
+
filename,
|
|
125
|
+
hash: await BinaryUtil.hashInput(createReadStream(location)),
|
|
126
|
+
size: (await fs.stat(location)).size,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
Object.assign(file, { [RawFileSymbol]: location });
|
|
130
|
+
|
|
131
|
+
return file;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
await remove();
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get file type
|
|
140
|
+
*/
|
|
141
|
+
static async getFileType(input: string | Readable): Promise<FileType> {
|
|
142
|
+
const { FileTypeParser } = await import('file-type');
|
|
143
|
+
const { fromStream } = await import('strtok3');
|
|
144
|
+
|
|
145
|
+
const parser = new FileTypeParser();
|
|
146
|
+
let tok: ReturnType<typeof fromStream> | undefined;
|
|
147
|
+
let matched: FileType | undefined;
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
tok = await fromStream(typeof input === 'string' ? createReadStream(input) : input);
|
|
151
|
+
matched = await parser.fromTokenizer(tok);
|
|
152
|
+
} finally {
|
|
153
|
+
await tok?.close();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!matched && typeof input === 'string') {
|
|
157
|
+
const { Mime } = (await import('mime'));
|
|
158
|
+
const otherTypes = (await import('mime/types/other.js')).default;
|
|
159
|
+
const standardTypes = (await import('mime/types/standard.js')).default;
|
|
160
|
+
const checker = new Mime(standardTypes, otherTypes);
|
|
161
|
+
const mime = checker.getType(input);
|
|
162
|
+
if (mime) {
|
|
163
|
+
return { ext: checker.getExtension(mime)!, mime };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return matched ?? { ext: 'bin', mime: 'application/octet-stream' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Finish upload
|
|
171
|
+
*/
|
|
172
|
+
static async finishUpload(upload: File, config: Partial<WebUploadConfig>): Promise<void> {
|
|
173
|
+
if (config.cleanupFiles !== false) {
|
|
174
|
+
await fs.rm(this.getUploadLocation(upload), { force: true });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Get Uploads
|
|
180
|
+
*/
|
|
181
|
+
static getRequestUploads(request: WebRequest & { [WebUploadSymbol]?: FileMap }): FileMap {
|
|
182
|
+
return request[WebUploadSymbol] ?? {};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Set Uploads
|
|
187
|
+
*/
|
|
188
|
+
static setRequestUploads(request: WebRequest & { [WebUploadSymbol]?: FileMap }, uploads: FileMap): void {
|
|
189
|
+
request[WebUploadSymbol] ??= uploads;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
File without changes
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
|
|
3
|
+
import { BinaryUtil, castTo } from '@travetto/runtime';
|
|
4
|
+
import { Controller, Post } from '@travetto/web';
|
|
5
|
+
import { BeforeAll, Suite, Test, TestFixtures } from '@travetto/test';
|
|
6
|
+
import { RootRegistry } from '@travetto/registry';
|
|
7
|
+
|
|
8
|
+
import { BaseWebSuite } from '@travetto/web/support/test/suite/base.ts';
|
|
9
|
+
|
|
10
|
+
import { Upload } from '../../src/decorator.ts';
|
|
11
|
+
import { FileMap } from '../../src/types.ts';
|
|
12
|
+
|
|
13
|
+
const bHash = (blob: Blob) => BinaryUtil.getBlobMeta(blob)?.hash;
|
|
14
|
+
|
|
15
|
+
@Controller('/test/upload')
|
|
16
|
+
class TestUploadController {
|
|
17
|
+
|
|
18
|
+
@Post('/all')
|
|
19
|
+
async uploadAll(@Upload() uploads: FileMap): Promise<{ hash?: string } | undefined> {
|
|
20
|
+
for (const [, blob] of Object.entries(uploads)) {
|
|
21
|
+
return { hash: bHash(blob) };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@Post('/')
|
|
26
|
+
async upload(@Upload() file: File) {
|
|
27
|
+
return { hash: bHash(file) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@Post('/all-named')
|
|
31
|
+
async uploads(@Upload('file1') file1: Blob, @Upload('file2') file2: Blob) {
|
|
32
|
+
return { hash1: bHash(file1), hash2: bHash(file2) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@Post('/all-named-custom')
|
|
36
|
+
async uploadVariousLimits(@Upload({ name: 'file1', types: ['!image/png'] }) file1: Blob, @Upload('file2') file2: Blob) {
|
|
37
|
+
return { hash1: bHash(file1), hash2: bHash(file2) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@Post('/all-named-size')
|
|
41
|
+
async uploadVariousSizeLimits(@Upload({ name: 'file1', maxSize: 100 }) file1: File, @Upload({ name: 'file2', maxSize: 8000 }) file2: File) {
|
|
42
|
+
return { hash1: bHash(file1), hash2: bHash(file2) };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@Suite()
|
|
47
|
+
export abstract class WebUploadServerSuite extends BaseWebSuite {
|
|
48
|
+
|
|
49
|
+
fixture: TestFixtures;
|
|
50
|
+
|
|
51
|
+
async getUploads(...files: { name: string, resource: string, type?: string }[]): Promise<FormData> {
|
|
52
|
+
const data = new FormData();
|
|
53
|
+
await Promise.all(files.map(async ({ name, type, resource }) => {
|
|
54
|
+
const file = await this.fixture.readFile(resource);
|
|
55
|
+
if (type) {
|
|
56
|
+
Object.defineProperty(file, 'type', { get: () => type });
|
|
57
|
+
}
|
|
58
|
+
data.append(name, file);
|
|
59
|
+
}));
|
|
60
|
+
return data;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@BeforeAll()
|
|
64
|
+
async init() {
|
|
65
|
+
this.fixture = new TestFixtures(['@travetto/asset']);
|
|
66
|
+
await RootRegistry.init();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@Test()
|
|
70
|
+
async testUploadAll() {
|
|
71
|
+
const uploads = await this.getUploads({ name: 'random', resource: 'logo.png', type: 'image/png' });
|
|
72
|
+
const response = await this.request<{ hash: string }>({ body: uploads, context: { httpMethod: 'POST', path: '/test/upload/all' } });
|
|
73
|
+
|
|
74
|
+
const file = await this.fixture.readStream('/logo.png');
|
|
75
|
+
assert(response.body?.hash === await BinaryUtil.hashInput(file));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@Test()
|
|
79
|
+
async testUploadDirect() {
|
|
80
|
+
const uploads = await this.getUploads({ name: 'file', resource: 'logo.png', type: 'image/png' });
|
|
81
|
+
const sent = castTo<Blob>(uploads.get('file'));
|
|
82
|
+
const response = await this.request<{ hash: string }>({ context: { httpMethod: 'POST', path: '/test/upload' }, body: sent });
|
|
83
|
+
|
|
84
|
+
const file = await this.fixture.readStream('/logo.png');
|
|
85
|
+
assert(response.body?.hash === await BinaryUtil.hashInput(file));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@Test()
|
|
89
|
+
async testUpload() {
|
|
90
|
+
const uploads = await this.getUploads({ name: 'file', resource: 'logo.png', type: 'image/png' });
|
|
91
|
+
const response = await this.request<{ hash: string }>({ body: uploads, context: { httpMethod: 'POST', path: '/test/upload' } });
|
|
92
|
+
|
|
93
|
+
const file = await this.fixture.readStream('/logo.png');
|
|
94
|
+
assert(response.body?.hash === await BinaryUtil.hashInput(file));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@Test()
|
|
98
|
+
async testMultiUpload() {
|
|
99
|
+
const uploads = await this.getUploads(
|
|
100
|
+
{ name: 'file1', resource: 'logo.png', type: 'image/png' },
|
|
101
|
+
{ name: 'file2', resource: 'logo.png', type: 'image/png' }
|
|
102
|
+
);
|
|
103
|
+
const response = await this.request<{ hash1: string, hash2: string }>({
|
|
104
|
+
body: uploads,
|
|
105
|
+
context: {
|
|
106
|
+
httpMethod: 'POST', path: '/test/upload/all-named'
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
const file = await this.fixture.readStream('/logo.png');
|
|
110
|
+
const hash = await BinaryUtil.hashInput(file);
|
|
111
|
+
|
|
112
|
+
assert(response.body?.hash1 === hash);
|
|
113
|
+
assert(response.body?.hash2 === hash);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@Test()
|
|
117
|
+
async testMultiUploadCustom() {
|
|
118
|
+
const uploadBad = await this.getUploads(
|
|
119
|
+
{ name: 'file1', resource: 'logo.png', type: 'image/png' },
|
|
120
|
+
{ name: 'file2', resource: 'logo.png', type: 'image/png' }
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const badResponse = await this.request<{ hash1: string, hash2: string }>({
|
|
124
|
+
body: uploadBad,
|
|
125
|
+
context: { httpMethod: 'POST', path: '/test/upload/all-named-custom' },
|
|
126
|
+
}, false);
|
|
127
|
+
assert(badResponse.context.httpStatusCode === 400);
|
|
128
|
+
|
|
129
|
+
const uploads = await this.getUploads(
|
|
130
|
+
{ name: 'file1', resource: 'logo.gif', type: 'image/gif' },
|
|
131
|
+
{ name: 'file2', resource: 'logo.png', type: 'image/png' }
|
|
132
|
+
);
|
|
133
|
+
const response = await this.request<{ hash1: string, hash2: string }>({
|
|
134
|
+
body: uploads,
|
|
135
|
+
context: { httpMethod: 'POST', path: '/test/upload/all-named-custom' },
|
|
136
|
+
}, false);
|
|
137
|
+
assert(response.context.httpStatusCode === 200);
|
|
138
|
+
|
|
139
|
+
const file1 = await this.fixture.readStream('/logo.gif');
|
|
140
|
+
const hash1 = await BinaryUtil.hashInput(file1);
|
|
141
|
+
|
|
142
|
+
const file2 = await this.fixture.readStream('/logo.png');
|
|
143
|
+
const hash2 = await BinaryUtil.hashInput(file2);
|
|
144
|
+
|
|
145
|
+
assert(response.body?.hash1 === hash1);
|
|
146
|
+
assert(response.body?.hash2 === hash2);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
@Test()
|
|
150
|
+
async testMultiUploadSize() {
|
|
151
|
+
const uploadBad = await this.getUploads(
|
|
152
|
+
{ name: 'file1', resource: 'logo.png', type: 'image/png' },
|
|
153
|
+
{ name: 'file2', resource: 'logo.png', type: 'image/png' }
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const badResponse = await this.request<{ hash1: string, hash2: string }>({
|
|
157
|
+
body: uploadBad,
|
|
158
|
+
context: { httpMethod: 'POST', path: '/test/upload/all-named-size' },
|
|
159
|
+
}, false);
|
|
160
|
+
assert(badResponse.context.httpStatusCode === 400);
|
|
161
|
+
|
|
162
|
+
const uploads = await this.getUploads(
|
|
163
|
+
{ name: 'file1', resource: 'asset.yml', type: 'text/plain' },
|
|
164
|
+
{ name: 'file2', resource: 'logo.png', type: 'image/png' }
|
|
165
|
+
);
|
|
166
|
+
const response = await this.request<{ hash1: string, hash2: string }>({
|
|
167
|
+
body: uploads,
|
|
168
|
+
context: { httpMethod: 'POST', path: '/test/upload/all-named-size' },
|
|
169
|
+
}, false);
|
|
170
|
+
assert(response.context.httpStatusCode === 200);
|
|
171
|
+
|
|
172
|
+
const file1 = await this.fixture.readStream('/asset.yml');
|
|
173
|
+
const hash1 = await BinaryUtil.hashInput(file1);
|
|
174
|
+
|
|
175
|
+
const file2 = await this.fixture.readStream('/logo.png');
|
|
176
|
+
const hash2 = await BinaryUtil.hashInput(file2);
|
|
177
|
+
|
|
178
|
+
assert(response.body?.hash1 === hash1);
|
|
179
|
+
assert(response.body?.hash2 === hash2);
|
|
180
|
+
}
|
|
181
|
+
}
|