Skip to content

Commit

Permalink
feat(file in multipart): stop storing files in temp dir, expose buffe…
Browse files Browse the repository at this point in the history
…r content as data in file obj

this change, stops and removes the storage of uploaded files in temp directory, the uploaded file's
buffer content is now exposed  in the file 's object

BREAKING CHANGE: The structure of file objects changes, path and key properties are removed, data
object is added, multiple file upload are now stored better as FileEntry[] instead of FileCollection
  • Loading branch information
teclone committed Dec 27, 2024
1 parent d7a1273 commit 39b76bf
Show file tree
Hide file tree
Showing 10 changed files with 174 additions and 259 deletions.
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@
"@teclone/utils": "^2.24.0",
"@types/node": "^14.14.33",
"dotenv": "^8.2.0",
"mime-types": "2.1.28",
"uuid": "^8.3.2"
"mime-types": "2.1.28"
}
}
3 changes: 3 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
const jsBeautify = require('js-beautify');

const { Server } = require('./build/cjs');
const { writeFileSync } = require('fs');
const server = new Server();

// process form upload from our examples index.html file
server.post('/', function (req, res) {
const result = JSON.stringify(Object.assign({}, req.body, req.files));

writeFileSync('./upload.pdf', req.files['file-cv'].data);
return res.json(jsBeautify.js(result));
});

Expand Down
42 changes: 3 additions & 39 deletions src/@types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,18 +150,10 @@ export interface FileEntry {
*/
name: string;

/**
* generated file name used in storing the file
*/
key: string;

/**
* file absolute path in storage
*/
path: string;
data: Buffer;

/**
* file size in bytes
* file size in bytes, same as the data.byteLength
*/
size: number;

Expand All @@ -171,35 +163,8 @@ export interface FileEntry {
type: string;
}

export interface FileEntryCollection {
/**
* file names in user machine as it was uploaded
*/
name: string[];

/**
* generated file names used in storing the file
*/
key: string[];

/**
* file absolute paths in storage
*/
path: string[];

/**
* file sizes in bytes
*/
size: number[];

/**
* file mimes type as supplied by the client
*/
type: string[];
}

export interface Files {
[fieldName: string]: FileEntry | FileEntryCollection;
[fieldName: string]: FileEntry | Array<FileEntry>;
}

export interface Data {
Expand All @@ -225,7 +190,6 @@ export interface MultipartHeaders {
isFile: boolean;
fileName: string;
fieldName: string;
encoding: string;
type: string;
}

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { RServerConfig } from './@types';

export { Server } from './modules/Server';
export { Router } from './modules/Router';
export { BodyParser } from './modules/BodyParser';

export { Http1Request, Http2Request } from './modules/Request';
export { Http1Response, Http2Response } from './modules/Response';
Expand Down
214 changes: 68 additions & 146 deletions src/modules/BodyParser.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,12 @@
import { mkDirSync } from '@teclone/node-utils';
import * as fs from 'fs';
import {
Data,
Files,
MultipartHeaders,
FileEntry,
FileEntryCollection,
} from '../@types/index';
import {
isArray,
isNull,
generateRandomText,
isObject,
makeArray,
} from '@teclone/utils';
import { Data, Files, MultipartHeaders, FileEntry } from '../@types/index';
import { generateRandomText } from '@teclone/utils';
import { CRLF, BLANK_LINE } from './Constants';
import { v1 as uuidv1 } from 'uuid';
import { resolve } from 'path';

export interface BodyParserConstructOpts {
tempDir: string;
encoding: BufferEncoding;
}

export class BodyParser {
private tempDir: string;
private encoding: BufferEncoding;

constructor(opts: BodyParserConstructOpts) {
this.tempDir = opts.tempDir;
this.encoding = opts.encoding;

//create file storage dir
mkDirSync(opts.tempDir);
}

/**
* resolves the field name by removing trailing bracket
*/
Expand All @@ -54,9 +26,8 @@ export class BodyParser {

let target: string | string[] = value;
if (isMultiValue) {
const previous = body[name];
target = isArray(previous) ? previous : [];
target.push(value);
target = body[name] || [];
(target as string[]).push(value);
}
body[name] = target;
}
Expand All @@ -66,47 +37,14 @@ export class BodyParser {
*/
private assignFileValue(files: Files, fieldName: string, value: FileEntry) {
const { name, isMultiValue } = this.resolveFieldName(fieldName);

let target: FileEntry | FileEntryCollection = value;
let target: FileEntry | FileEntry[] = value;
if (isMultiValue) {
const previous = files[name];
target = isObject<FileEntryCollection>(previous)
? previous
: {
path: [],
key: [],
size: [],
type: [],
name: [],
};

Object.keys(value).forEach((key) => {
target[key].push(value[key]);
});
target = files[name] || [];
(target as FileEntry[]).push(value);
}
files[name] = target;
}

/**
* processes and stores file
*/
private processFile(headers: MultipartHeaders, content: string): FileEntry {
const key = uuidv1() + '.tmp';
const filePath = resolve(this.tempDir, key);

fs.writeFileSync(filePath, content, {
encoding: headers.encoding as BufferEncoding,
});

return {
name: headers.fileName.replace(/\.\./g, ''),
key,
path: filePath,
size: fs.statSync(filePath).size,
type: headers.type,
};
}

/**
* parses a multipart part headers.
*/
Expand All @@ -117,35 +55,35 @@ export class BodyParser {
type: 'text/plain',
fileName: '',
fieldName: generateRandomText(8),
encoding: this.encoding,
};

for (let i = 0; i < headers.length; i++) {
const header = headers[i];
if (header !== '') {
const headerPair = header.split(/\s*:\s*/);
const name = headerPair[0];
let value = headerPair[1];

switch (name.toLowerCase()) {
case 'content-disposition':
//capture file name
if (/filename="([^"]+)"/.exec(value)) {
result.fileName = RegExp.$1;
}
// capture field name
value = value.replace(/filename="([^"]+)"/, '');
/* istanbul ignore else */
if (/name="([^"]+)"/.exec(value)) {
result.fieldName = RegExp.$1;
}
break;

case 'content-type':
result.isFile = true;
result.type = value.split(/;\s*/)[0];
break;
}
if (!header) {
continue;
}
const headerPair = header.split(/\s*:\s*/);
const name = headerPair[0];
let value = headerPair[1];

switch (name.toLowerCase()) {
case 'content-disposition':
//capture file name
if (/filename="([^"]+)"/.exec(value)) {
result.fileName = RegExp.$1;
}
// capture field name
value = value.replace(/filename="([^"]+)"/, '');
/* istanbul ignore else */
if (/name="([^"]+)"/.exec(value)) {
result.fieldName = RegExp.$1;
}
break;

case 'content-type':
result.isFile = true;
result.type = value.split(/;\s*/)[0];
break;
}
}
return result;
Expand All @@ -155,51 +93,57 @@ export class BodyParser {
* parse multipart form data
*@see https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html
*/
private parseMultiPart(string: string, boundary: string | null) {
private parseMultiPart(string: string, headerBoundary: string | null) {
const body = {},
files = {};

//if boundary is null, detect it using the last encapsulation boundary format
if (isNull(boundary)) {
if (!headerBoundary) {
if (!/^-{2}(-*[a-z0-9]+)-{2}/gim.test(string)) {
return { body, files };
}
boundary = RegExp.$1;
headerBoundary = RegExp.$1;
}

//some implementations fails to start the first encapsulation boundary
// with CRLF character when there is no preamble
const testPattern = new RegExp(`^--${boundary}`, 'gm');
/* istanbul ignore else */
if (testPattern.test(string)) {
const boundary = '--' + headerBoundary;
if (string.startsWith(boundary)) {
string = CRLF + string;
}

// extract body parts, dropping preamble and epilogue
const bodyParts = string
.split(new RegExp(`${CRLF}${boundary}`, 'gm'))
.slice(1, -1);

const crlfPattern = new RegExp(CRLF, 'gm');
const boundaryLinePattern = new RegExp(`${CRLF}--${boundary}`, 'gm');

const blankLinePattern = new RegExp(BLANK_LINE, 'gm');

//obtain body parts, discard preamble and epilogue
string
.split(boundaryLinePattern)
.slice(1, -1)
.forEach((bodyPart) => {
const [header, ...others] = bodyPart.split(blankLinePattern);
bodyParts.forEach((bodyPart) => {
const [header, ...others] = bodyPart.split(blankLinePattern);

const content = others.join(BLANK_LINE);
const headers = this.parseHeaders(header.split(crlfPattern));
const content = others.join(BLANK_LINE);
const headers = this.parseHeaders(header.split(crlfPattern));

/* istanbul ignore else */
if (headers.isFile && headers.fileName) {
this.assignFileValue(
files,
headers.fieldName,
this.processFile(headers, content)
);
} else if (!headers.isFile) {
this.assignBodyValue(body, headers.fieldName, content);
}
});
/* istanbul ignore else */
if (headers.isFile && headers.fileName) {
const data = Buffer.from(
content,
headers.type.startsWith('text/') ? 'utf8' : 'binary'
);
this.assignFileValue(files, headers.fieldName, {
name: headers.fileName.replace(/\.\./g, ''),
data,
size: data.byteLength,
type: headers.type,
});
} else if (!headers.isFile) {
this.assignBodyValue(body, headers.fieldName, content);
}
});

return { body, files };
}
Expand Down Expand Up @@ -264,10 +208,13 @@ export class BodyParser {
*@param {string} contentType - the request content type
*/
parse(buffer: Buffer, contentType: string): { files: Files; body: Data } {
const content = buffer.toString(this.encoding);
const content = buffer.toString('latin1');
const tokens = contentType.split(/;\s*/);

let boundary: string | null = null;
if (tokens[0].startsWith('multipart')) {
const matches = /boundary\s*=\s*([^;\s,]+)/.exec(contentType);
return this.parseMultiPart(content, matches?.[1]);
}

switch (tokens[0].toLowerCase()) {
case 'text/plain':
Expand All @@ -278,33 +225,8 @@ export class BodyParser {
case 'application/json':
return { files: {}, body: this.parseJSON(content) };

case 'multipart/form-data':
if (tokens.length === 2 && /boundary\s*=\s*/.test(tokens[1])) {
boundary = tokens[1].split('=')[1];
}
return this.parseMultiPart(content, boundary);

default:
return { body: {}, files: {} };
}
}

/**
* clean up temp files
*/
cleanUpTempFiles(files: Files) {
const unlink = (path) => {
if (fs.existsSync(path)) {
fs.unlinkSync(path);
}
};

const keys = Object.keys(files);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const file = files[key];

makeArray(file.path).forEach(unlink);
}
}
}
Loading

0 comments on commit 39b76bf

Please sign in to comment.