Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s
176 lines
4.3 KiB
TypeScript
176 lines
4.3 KiB
TypeScript
import { Readable } from 'node:stream';
|
|
import { ReadableStream } from 'node:stream/web';
|
|
|
|
import { MeteorError } from '@rocket.chat/core-services';
|
|
import type { ValidateFunction } from 'ajv';
|
|
import busboy from 'busboy';
|
|
|
|
import { getMimeType } from '../../../app/utils/lib/mimeTypes';
|
|
|
|
type UploadResult<K> = {
|
|
file: Readable & { truncated: boolean };
|
|
fieldname: string;
|
|
filename: string;
|
|
encoding: string;
|
|
mimetype: string;
|
|
fileBuffer: Buffer;
|
|
fields: K;
|
|
};
|
|
|
|
type UploadResultWithOptionalFile<K> =
|
|
| UploadResult<K>
|
|
| ({
|
|
[P in keyof Omit<UploadResult<K>, 'fields'>]: undefined;
|
|
} & {
|
|
fields: K;
|
|
});
|
|
|
|
export async function getUploadFormData<
|
|
T extends string,
|
|
K extends Record<string, string> = Record<string, string>,
|
|
V extends ValidateFunction<K> = ValidateFunction<K>,
|
|
>(
|
|
{ request }: { request: Request },
|
|
options: {
|
|
field?: T;
|
|
validate?: V;
|
|
sizeLimit?: number;
|
|
fileOptional: true;
|
|
},
|
|
): Promise<UploadResultWithOptionalFile<K>>;
|
|
|
|
export async function getUploadFormData<
|
|
T extends string,
|
|
K extends Record<string, string> = Record<string, string>,
|
|
V extends ValidateFunction<K> = ValidateFunction<K>,
|
|
>(
|
|
{ request }: { request: Request },
|
|
options?: {
|
|
field?: T;
|
|
validate?: V;
|
|
sizeLimit?: number;
|
|
fileOptional?: false | undefined;
|
|
},
|
|
): Promise<UploadResult<K>>;
|
|
|
|
export async function getUploadFormData<
|
|
T extends string,
|
|
K extends Record<string, string> = Record<string, string>,
|
|
V extends ValidateFunction<K> = ValidateFunction<K>,
|
|
>(
|
|
{ request }: { request: Request },
|
|
options: {
|
|
field?: T;
|
|
validate?: V;
|
|
sizeLimit?: number;
|
|
fileOptional?: boolean;
|
|
} = {},
|
|
): Promise<UploadResultWithOptionalFile<K>> {
|
|
if (!(request.body instanceof ReadableStream)) {
|
|
return Promise.reject(new MeteorError('Invalid request body'));
|
|
}
|
|
|
|
const limits = {
|
|
files: 1,
|
|
...(options.sizeLimit && options.sizeLimit > -1 && { fileSize: options.sizeLimit }),
|
|
};
|
|
|
|
const bb = busboy({ headers: Object.fromEntries(request.headers.entries()), defParamCharset: 'utf8', limits });
|
|
const fields = Object.create(null) as K;
|
|
|
|
let uploadedFile: UploadResultWithOptionalFile<K> | undefined = {
|
|
fields,
|
|
encoding: undefined,
|
|
filename: undefined,
|
|
fieldname: undefined,
|
|
mimetype: undefined,
|
|
fileBuffer: undefined,
|
|
file: undefined,
|
|
};
|
|
|
|
const { promise: resultPromise, resolve, reject } = Promise.withResolvers<UploadResultWithOptionalFile<K>>();
|
|
|
|
function onField(fieldname: keyof K, value: K[keyof K]) {
|
|
fields[fieldname] = value;
|
|
}
|
|
|
|
function onEnd() {
|
|
if (!uploadedFile) {
|
|
return reject(new MeteorError('No file or fields were uploaded'));
|
|
}
|
|
if (!options.fileOptional && !uploadedFile?.file) {
|
|
return reject(new MeteorError('No file uploaded'));
|
|
}
|
|
if (options.validate !== undefined && !options.validate(fields)) {
|
|
return reject(
|
|
new MeteorError(`Invalid fields ${(options.validate.errors ?? []).map((e) => `${e.instancePath} ${e.message}`).join(', ')}`),
|
|
);
|
|
}
|
|
return resolve(uploadedFile);
|
|
}
|
|
|
|
function onFile(
|
|
fieldname: string,
|
|
file: Readable & { truncated: boolean },
|
|
{ filename, encoding, mimeType: mimetype }: { filename: string; encoding: string; mimeType: string },
|
|
) {
|
|
if (options.field && fieldname !== options.field) {
|
|
file.resume();
|
|
return reject(new MeteorError('invalid-field'));
|
|
}
|
|
|
|
const fileChunks: Uint8Array[] = [];
|
|
file.on('data', (chunk) => {
|
|
fileChunks.push(chunk);
|
|
});
|
|
|
|
file.on('end', () => {
|
|
if (file.truncated) {
|
|
fileChunks.length = 0;
|
|
return reject(new MeteorError('error-file-too-large'));
|
|
}
|
|
|
|
uploadedFile = {
|
|
file,
|
|
filename,
|
|
encoding,
|
|
mimetype: getMimeType(mimetype, filename),
|
|
fieldname,
|
|
fields,
|
|
fileBuffer: Buffer.concat(fileChunks),
|
|
};
|
|
});
|
|
}
|
|
|
|
function cleanup() {
|
|
bb.removeAllListeners();
|
|
}
|
|
|
|
bb.on('field', onField);
|
|
bb.on('file', onFile);
|
|
bb.on('close', cleanup);
|
|
bb.on('end', onEnd);
|
|
bb.on('finish', onEnd);
|
|
|
|
bb.on('error', (err: Error) => {
|
|
reject(err);
|
|
});
|
|
|
|
bb.on('partsLimit', () => {
|
|
reject();
|
|
});
|
|
bb.on('filesLimit', () => {
|
|
reject('Just 1 file is allowed');
|
|
});
|
|
bb.on('fieldsLimit', () => {
|
|
reject();
|
|
});
|
|
|
|
// Unclear why typescript complains that the ReadableStream from request.body is incompatible here
|
|
Readable.fromWeb(request.body satisfies ReadableStream)
|
|
.on('error', (err) => reject(err))
|
|
.pipe(bb);
|
|
|
|
return resultPromise;
|
|
}
|