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
206 lines
5.4 KiB
TypeScript
206 lines
5.4 KiB
TypeScript
import fs from 'node:fs';
|
|
import { IncomingMessage } from 'node:http';
|
|
import type { Stream, Transform } from 'node:stream';
|
|
import { Readable } from 'node:stream';
|
|
import { pipeline } from 'node:stream/promises';
|
|
|
|
import { MeteorError } from '@rocket.chat/core-services';
|
|
import { Random } from '@rocket.chat/random';
|
|
import busboy, { type BusboyConfig } from 'busboy';
|
|
import ExifTransformer from 'exif-be-gone';
|
|
|
|
import { getMimeType } from '../../../app/utils/lib/mimeTypes';
|
|
import { UploadFS } from '../../ufs';
|
|
|
|
export type ParsedUpload = {
|
|
tempFilePath: string;
|
|
filename: string;
|
|
mimetype: string;
|
|
size: number;
|
|
fieldname: string;
|
|
};
|
|
|
|
export type ParseOptions = {
|
|
field: string;
|
|
maxSize?: number;
|
|
allowedMimeTypes?: string[];
|
|
transforms?: Transform[]; // Optional transform pipeline (e.g., EXIF stripping)
|
|
fileOptional?: boolean;
|
|
};
|
|
|
|
export class MultipartUploadHandler {
|
|
static transforms = {
|
|
stripExif(): Transform {
|
|
return new ExifTransformer();
|
|
},
|
|
};
|
|
|
|
static async cleanup(tempFilePath: string): Promise<void> {
|
|
try {
|
|
await fs.promises.unlink(tempFilePath);
|
|
} catch (error: any) {
|
|
console.warn(`[UploadService] Failed to cleanup temp file: ${tempFilePath}`, error);
|
|
}
|
|
}
|
|
|
|
static async stripExifFromFile(tempFilePath: string): Promise<number> {
|
|
const strippedPath = `${tempFilePath}.stripped`;
|
|
|
|
try {
|
|
const writeStream = fs.createWriteStream(strippedPath);
|
|
|
|
await pipeline(fs.createReadStream(tempFilePath), new ExifTransformer(), writeStream);
|
|
|
|
await fs.promises.rename(strippedPath, tempFilePath);
|
|
|
|
return writeStream.bytesWritten;
|
|
} catch (error) {
|
|
void this.cleanup(strippedPath);
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
static async parseRequest(
|
|
request: IncomingMessage | Request,
|
|
options: ParseOptions,
|
|
): Promise<{ file: ParsedUpload | null; fields: Record<string, string> }> {
|
|
const limits: BusboyConfig['limits'] = { files: 1 };
|
|
|
|
if (options.maxSize && options.maxSize > 0) {
|
|
// We add an extra byte to the configured limit so we don't fail the upload
|
|
// of a file that is EXACTLY maxSize
|
|
limits.fileSize = options.maxSize + 1;
|
|
}
|
|
|
|
const headers =
|
|
request instanceof IncomingMessage ? (request.headers as Record<string, string>) : Object.fromEntries(request.headers.entries());
|
|
|
|
const bb = busboy({
|
|
headers,
|
|
defParamCharset: 'utf8',
|
|
limits,
|
|
});
|
|
|
|
const fields: Record<string, string> = {};
|
|
let parsedFile: ParsedUpload | null = null;
|
|
let busboyFinished = false;
|
|
let filePendingCount = 0;
|
|
|
|
const { promise, resolve, reject } = Promise.withResolvers<{
|
|
file: ParsedUpload | null;
|
|
fields: Record<string, string>;
|
|
}>();
|
|
|
|
const tryResolve = () => {
|
|
if (busboyFinished && filePendingCount < 1) {
|
|
if (!parsedFile && !options.fileOptional) {
|
|
return reject(new MeteorError('error-no-file', 'No file uploaded'));
|
|
}
|
|
resolve({ file: parsedFile, fields });
|
|
}
|
|
};
|
|
|
|
bb.on('field', (fieldname: string, value: string) => {
|
|
fields[fieldname] = value;
|
|
});
|
|
|
|
bb.on('file', (fieldname, file, info) => {
|
|
const { filename, mimeType } = info;
|
|
|
|
++filePendingCount;
|
|
|
|
if (options.field && fieldname !== options.field) {
|
|
file.resume();
|
|
return reject(new MeteorError('invalid-field'));
|
|
}
|
|
|
|
if (options.allowedMimeTypes && !options.allowedMimeTypes.includes(mimeType)) {
|
|
file.resume();
|
|
return reject(new MeteorError('error-invalid-file-type', `File type ${mimeType} not allowed`));
|
|
}
|
|
|
|
const fileId = Random.id();
|
|
const tempFilePath = UploadFS.getTempFilePath(fileId);
|
|
|
|
const writeStream = fs.createWriteStream(tempFilePath);
|
|
|
|
let currentStream: Stream = file;
|
|
if (options.transforms?.length) {
|
|
const fileDestroyer = file.destroy.bind(file);
|
|
for (const transform of options.transforms) {
|
|
transform.on('error', fileDestroyer);
|
|
currentStream = currentStream.pipe(transform);
|
|
}
|
|
}
|
|
|
|
currentStream.pipe(writeStream);
|
|
|
|
writeStream.on('finish', () => {
|
|
if (file.truncated) {
|
|
void this.cleanup(tempFilePath);
|
|
return reject(new MeteorError('error-file-too-large', 'File size exceeds the allowed limit'));
|
|
}
|
|
|
|
parsedFile = {
|
|
tempFilePath,
|
|
filename,
|
|
mimetype: getMimeType(mimeType, filename),
|
|
size: writeStream.bytesWritten,
|
|
fieldname,
|
|
};
|
|
|
|
--filePendingCount;
|
|
|
|
tryResolve();
|
|
});
|
|
|
|
writeStream.on('error', (err) => {
|
|
file.destroy();
|
|
void this.cleanup(tempFilePath);
|
|
reject(new MeteorError('error-file-upload', err.message));
|
|
});
|
|
|
|
file.on('error', (err) => {
|
|
writeStream.destroy();
|
|
void this.cleanup(tempFilePath);
|
|
reject(new MeteorError('error-file-upload', err.message));
|
|
});
|
|
});
|
|
|
|
bb.on('finish', () => {
|
|
busboyFinished = true;
|
|
tryResolve();
|
|
});
|
|
|
|
bb.on('error', (err: any) => {
|
|
reject(new MeteorError('error-upload-failed', err.message));
|
|
});
|
|
|
|
bb.on('filesLimit', () => {
|
|
reject(new MeteorError('error-too-many-files', 'Too many files in upload'));
|
|
});
|
|
|
|
bb.on('partsLimit', () => {
|
|
reject(new MeteorError('error-too-many-parts', 'Too many parts in upload'));
|
|
});
|
|
|
|
bb.on('fieldsLimit', () => {
|
|
reject(new MeteorError('error-too-many-fields', 'Too many fields in upload'));
|
|
});
|
|
|
|
if (request instanceof IncomingMessage) {
|
|
request.pipe(bb);
|
|
} else {
|
|
if (!request.body) {
|
|
return Promise.reject(new MeteorError('error-no-body', 'Request has no body'));
|
|
}
|
|
|
|
const nodeStream = Readable.fromWeb(request.body as any);
|
|
nodeStream.pipe(bb);
|
|
}
|
|
|
|
return promise;
|
|
}
|
|
}
|