snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
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 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
import { UploadFS as UFS } from './ufs';
import './ufs-methods';
import './ufs-server';
import './ufs-gridfs';
import './ufs-local';
export const UploadFS = UFS;

View File

@@ -0,0 +1,56 @@
type ConfigOptions = {
https?: boolean;
simulateUploadSpeed?: number;
storesPath?: string;
tmpDir?: string;
tmpDirPermissions?: string;
};
type RequiredConfigOptions = Required<ConfigOptions>;
export class Config {
public https: RequiredConfigOptions['https'];
public simulateUploadSpeed: RequiredConfigOptions['simulateUploadSpeed'];
public storesPath: RequiredConfigOptions['storesPath'];
public tmpDir: RequiredConfigOptions['tmpDir'];
public tmpDirPermissions: RequiredConfigOptions['tmpDirPermissions'];
constructor(options: ConfigOptions = {}) {
// Default options
options = {
https: false,
simulateUploadSpeed: 0,
storesPath: 'ufs',
tmpDir: '/tmp/ufs',
tmpDirPermissions: '0700',
...options,
};
// Check options
if (typeof options.https !== 'boolean') {
throw new TypeError('Config: https is not a function');
}
if (typeof options.simulateUploadSpeed !== 'number') {
throw new TypeError('Config: simulateUploadSpeed is not a number');
}
if (typeof options.storesPath !== 'string') {
throw new TypeError('Config: storesPath is not a string');
}
if (typeof options.tmpDir !== 'string') {
throw new TypeError('Config: tmpDir is not a string');
}
if (typeof options.tmpDirPermissions !== 'string') {
throw new TypeError('Config: tmpDirPermissions is not a string');
}
this.https = options.https;
this.simulateUploadSpeed = options.simulateUploadSpeed;
this.storesPath = options.storesPath;
this.tmpDir = options.tmpDir;
this.tmpDirPermissions = options.tmpDirPermissions;
}
}

View File

@@ -0,0 +1,143 @@
import type { IUpload } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import type { OptionalId } from 'mongodb';
type IFilterOptions = {
contentTypes?: string[];
extensions?: string[];
minSize?: number;
maxSize?: number;
onCheck?: (file: IUpload, content?: Buffer | string) => Promise<boolean>;
invalidFileError?: () => Meteor.Error;
fileTooSmallError?: (fileSize: number, minFileSize: number) => Meteor.Error;
fileTooLargeError?: (fileSize: number, maxFileSize: number) => Meteor.Error;
invalidFileExtension?: (fileExtension: string, allowedExtensions: string[]) => Meteor.Error;
invalidFileType?: (fileType: string | undefined, allowedContentTypes: string[]) => Meteor.Error;
};
export class Filter {
private options: Required<IFilterOptions>;
constructor(options: IFilterOptions) {
this.options = {
contentTypes: [],
extensions: [],
minSize: 1,
maxSize: 0,
invalidFileError: () => new Meteor.Error('invalid-file', 'File is not valid'),
fileTooSmallError: (fileSize, minFileSize) =>
new Meteor.Error('file-too-small', `File size (size = ${fileSize}) is too small (min = ${minFileSize})`),
fileTooLargeError: (fileSize, maxFileSize) =>
new Meteor.Error('file-too-large', `File size (size = ${fileSize}) is too large (max = ${maxFileSize})`),
invalidFileExtension: (fileExtension, allowedExtensions) =>
new Meteor.Error('invalid-file-extension', `File extension "${fileExtension}" is not accepted (${allowedExtensions})`),
invalidFileType: (fileType, allowedContentTypes) =>
new Meteor.Error('invalid-file-type', `File type "${fileType}" is not accepted (${allowedContentTypes})`),
onCheck: this.onCheck,
...options,
};
// Check options
if (this.options.contentTypes && !(this.options.contentTypes instanceof Array)) {
throw new TypeError('Filter: contentTypes is not an Array');
}
if (this.options.extensions && !(this.options.extensions instanceof Array)) {
throw new TypeError('Filter: extensions is not an Array');
}
if (typeof this.options.minSize !== 'number') {
throw new TypeError('Filter: minSize is not a number');
}
if (typeof this.options.maxSize !== 'number') {
throw new TypeError('Filter: maxSize is not a number');
}
if (this.options.onCheck && typeof this.options.onCheck !== 'function') {
throw new TypeError('Filter: onCheck is not a function');
}
if (typeof this.options.onCheck === 'function') {
this.onCheck = this.options.onCheck;
}
}
async check(file: Omit<OptionalId<IUpload>, '_updatedAt'>, content?: Buffer | string) {
let error = null;
if (typeof file !== 'object' || !file) {
error = this.options.invalidFileError();
}
// Check size
const fileSize = file.size || 0;
const minSize = this.getMinSize();
if (fileSize <= 0 || fileSize < minSize) {
error = this.options.fileTooSmallError(fileSize, minSize);
}
const maxSize = this.getMaxSize();
if (maxSize > 0 && fileSize > maxSize) {
error = this.options.fileTooLargeError(fileSize, maxSize);
}
// Check extension
const allowedExtensions = this.getExtensions();
const fileExtension = file.extension || '';
if (allowedExtensions.length && !allowedExtensions.includes(fileExtension)) {
error = this.options.invalidFileExtension(fileExtension, allowedExtensions);
}
// Check content type
const allowedContentTypes = this.getContentTypes();
const fileTypes = file.type;
if (allowedContentTypes.length && !this.isContentTypeInList(fileTypes, allowedContentTypes)) {
error = this.options.invalidFileType(fileTypes, allowedContentTypes);
}
// Apply custom check
if (typeof this.onCheck === 'function' && !(await this.onCheck(file, content))) {
error = new Meteor.Error('invalid-file', 'File does not match filter');
}
if (error) {
throw error;
}
}
getContentTypes() {
return this.options.contentTypes;
}
getExtensions() {
return this.options.extensions;
}
getMaxSize() {
return this.options.maxSize;
}
getMinSize() {
return this.options.minSize;
}
isContentTypeInList(type: string | undefined, list: string[]) {
if (typeof type === 'string' && list instanceof Array) {
if (list.includes(type)) {
return true;
}
const wildCardGlob = '/*';
const wildcards = list.filter((item) => item.indexOf(wildCardGlob) > 0);
if (wildcards.includes(type.replace(/(\/.*)$/, wildCardGlob))) {
return true;
}
}
return false;
}
async isValid(file: IUpload) {
let result = true;
try {
await this.check(file);
} catch (err) {
result = false;
}
return result;
}
async onCheck(_file: Omit<OptionalId<IUpload>, '_updatedAt'>, _content?: Buffer | string) {
return true;
}
}

View File

@@ -0,0 +1,92 @@
import type { IUpload } from '@rocket.chat/core-typings';
import { MongoInternals } from 'meteor/mongo';
import { NpmModuleMongodb } from 'meteor/npm-mongo';
import type { ObjectId } from 'mongodb';
import { UploadFS } from './ufs';
import type { StoreOptions } from './ufs-store';
type GridFSStoreOptions = StoreOptions & {
chunkSize: number;
collectionName: string;
};
export class GridFSStore extends UploadFS.Store {
protected chunkSize: number;
protected collectionName: string;
constructor(options: GridFSStoreOptions) {
// Default options
options = Object.assign(
{
chunkSize: 1024 * 255,
collectionName: 'uploadfs',
},
options,
);
// Check options
if (typeof options.chunkSize !== 'number') {
throw new TypeError('GridFSStore: chunkSize is not a number');
}
if (typeof options.collectionName !== 'string') {
throw new TypeError('GridFSStore: collectionName is not a string');
}
super(options);
this.chunkSize = options.chunkSize;
this.collectionName = options.collectionName;
// const mongo = MongoInternals.NpmModule;
const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo;
const mongoStore = new NpmModuleMongodb.GridFSBucket(db as any, {
bucketName: options.collectionName,
chunkSizeBytes: this.chunkSize,
});
this.delete = async function (fileId) {
const collectionName = `${options.collectionName}.files`;
const file = await db.collection(collectionName).findOne({ _id: fileId as any });
if (file) {
await mongoStore.delete(fileId as unknown as ObjectId);
}
};
this.getReadStream = async function (fileId: string, _file: IUpload, options?: { end?: number }) {
options = Object.assign({}, options);
// https://mongodb.github.io/node-mongodb-native/4.4/interfaces/GridFSBucketReadStreamOptionsWithRevision.html#end
// according to the mongodb doc, the end is 0-based offset in bytes to stop streaming before -<< BEFORE
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range
// <range-end> an integer in the given unit indicating the end position (zero-indexed & inclusive) of the requested range. -<< inclusive
// so before there always one byte miss, then browser will send a start=end request, with case fail to get the DB's last byte
// this will leads to audio's duration Infinite and keep waiting...
if (options?.end) {
options.end += 1;
}
return mongoStore.openDownloadStream(fileId as unknown as ObjectId, options);
};
this.getWriteStream = async function (fileId: string, file: IUpload, _options?: any) {
const writeStream = mongoStore.openUploadStreamWithId(fileId as unknown as ObjectId, fileId, {
chunkSizeBytes: this.chunkSize,
contentType: file.type,
});
let finished = false;
writeStream.on('finish', () => {
finished = true;
});
writeStream.on('close', () => {
if (!finished) {
writeStream.emit('finish');
}
});
return writeStream;
};
}
}
// Add store to UFS namespace
UploadFS.store.GridFS = GridFSStore;

View File

@@ -0,0 +1,115 @@
import fs from 'node:fs';
import { expect } from 'chai';
import { before, beforeEach, afterEach, describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
import type { SinonStub } from 'sinon';
// import { Config } from './ufs-config';
// import { UploadFS } from './ufs';
import type { LocalStore as LocalStoreClass } from './ufs-local';
const fakeCollection = {
removeById: sinon.stub(),
findOne: sinon.stub(),
};
// Proxyquire chain to mock all Meteor dependencies
const ufsFilterMock = proxyquire.noCallThru().load('./ufs-filter', {
'meteor/meteor': {},
'meteor/check': {},
'meteor/mongo': {},
'meteor/npm-mongo': {},
});
const { UploadFS } = proxyquire.noCallThru().load('./ufs', {
'meteor/meteor': {},
'meteor/check': {},
'meteor/mongo': {},
'meteor/npm-mongo': {},
'./ufs-filter': ufsFilterMock,
'./ufs-store': {
Store: Object,
},
});
// Create the UploadFS mock object as a callable function with properties
// function UploadFS() {
// return UploadFS;
// }
// Object.assign(UploadFS, {
// store: {},
// config: new Config(),
// addStore: sinon.stub(),
// getStore: sinon.stub().returns(undefined),
// getTempFilePath: sinon.stub().returns('/tmp/mockfile'),
// });
// The module must export { UploadFS: ... }
const ufsMock = { UploadFS };
const ufsMockModule = { UploadFS };
const ufsIndexMock = { UploadFS };
const ufsStoreMockRaw = proxyquire.noCallThru().load('./ufs-store', {
'meteor/meteor': {},
'meteor/check': {},
'meteor/mongo': {},
'meteor/npm-mongo': {},
'./ufs': ufsMockModule,
'./ufs-filter': ufsFilterMock,
'./index': ufsIndexMock,
});
// Patch ufsStoreMock to export all possible ways (default, named, etc)
const ufsStoreMock = Object.assign({}, ufsStoreMockRaw, { default: ufsStoreMockRaw, ufsStoreMock: ufsStoreMockRaw });
const localStoreProxy = proxyquire.noCallThru().load('./ufs-local', {
// 'mkdirp': sinon.stub(),
'meteor/meteor': {},
'meteor/check': {},
'meteor/mongo': {},
'meteor/npm-mongo': {},
'./ufs': ufsMock,
'./ufs-store': ufsStoreMock,
'./index': ufsIndexMock,
});
const { LocalStore } = localStoreProxy as {
LocalStore: typeof LocalStoreClass;
};
describe('LocalStore', () => {
let store: LocalStoreClass;
let unlinkStub: SinonStub;
before(() => {
fakeCollection.removeById.resolves();
fakeCollection.findOne.resolves({ _id: 'test', name: 'file.txt' });
store = new LocalStore({ name: 'test', collection: fakeCollection as any, path: '/tmp/ufs-local' });
});
beforeEach(() => {
unlinkStub = sinon.stub(fs.promises, 'unlink').resolves();
fakeCollection.removeById.resetHistory();
fakeCollection.findOne.resetHistory();
});
afterEach(() => {
unlinkStub.restore();
});
it('should not throw if file does not exist (ENOENT)', async () => {
unlinkStub.rejects(Object.assign(new Error('not found'), { code: 'ENOENT' }));
await expect(store.delete('test')).to.be.fulfilled;
// unlink is called twice: once for the temp file, once for the actual file
expect(unlinkStub.calledTwice).to.be.true;
});
it('should throw if unlink fails with non-ENOENT error', async () => {
unlinkStub.rejects(Object.assign(new Error('fail'), { code: 'EACCES' }));
await expect(store.delete('test')).to.be.rejectedWith('fail');
});
it('should call findOne to get file info', async () => {
await store.delete('test');
expect(fakeCollection.findOne.calledWith('test')).to.be.true;
});
});

View File

@@ -0,0 +1,111 @@
import fs from 'node:fs';
import { unlink } from 'node:fs/promises';
import { isNativeError } from 'node:util/types';
import type { IUpload } from '@rocket.chat/core-typings';
import mkdirp from 'mkdirp';
import { UploadFS } from './ufs';
import type { StoreOptions } from './ufs-store';
import { Store } from './ufs-store';
type LocalStoreOptions = StoreOptions & {
mode?: string;
path?: string;
writeMode?: number;
};
export class LocalStore extends Store {
protected getPath: (file: string) => string;
constructor(options: LocalStoreOptions) {
// Default options
options = {
mode: '0744',
path: 'ufs/uploads',
writeMode: 0o744,
...options,
};
// Check options
if (typeof options.mode !== 'string') {
throw new TypeError('LocalStore: mode is not a string');
}
if (typeof options.path !== 'string') {
throw new TypeError('LocalStore: path is not a string');
}
if (typeof options.writeMode !== 'number') {
throw new TypeError('LocalStore: writeMode is not a string');
}
super(options);
// Private attributes
const { mode } = options;
const { path } = options;
const { writeMode } = options;
fs.stat(path, (err) => {
if (err) {
// Create the directory
mkdirp(path, { mode })
.then(() => {
console.info(`LocalStore: store created at ${path}`);
})
.catch((err) => {
console.error(`LocalStore: cannot create store at ${path} (${err.message})`);
});
} else {
// Set directory permissions
fs.chmod(path, mode, (err) => {
err && console.error(`LocalStore: cannot set store permissions ${mode} (${err.message})`);
});
}
});
this.getPath = function (file) {
return path + (file ? `/${file}` : '');
};
this.delete = async (fileId, options) => {
const path = await this.getFilePath(fileId);
try {
await unlink(path);
} catch (error) {
if (!isNativeError(error) || !('code' in error) || !(error.code === 'ENOENT')) {
throw error;
}
}
await this.removeById(fileId, { session: options?.session }, true);
};
this.getReadStream = async (fileId: string, file: IUpload, options?: { start?: number; end?: number }) => {
options = Object.assign({}, options);
return fs.createReadStream(await this.getFilePath(fileId, file), {
flags: 'r',
encoding: undefined,
autoClose: true,
start: options.start,
end: options.end,
});
};
this.getWriteStream = async (fileId: string, file: IUpload, options?: { start?: number }) => {
options = Object.assign({}, options);
return fs.createWriteStream(await this.getFilePath(fileId, file), {
flags: 'a',
encoding: undefined,
mode: writeMode,
start: options.start,
});
};
}
override async getFilePath(fileId: string, fileParam?: IUpload): Promise<string> {
const file = fileParam || (await this.getCollection().findOne(fileId, { projection: { extension: 1 } }));
return (file && this.getPath(fileId + (file.extension ? `.${file.extension}` : ''))) || '';
}
}
// Add store to UFS namespace
UploadFS.store.Local = LocalStore;

View File

@@ -0,0 +1,78 @@
import fs from 'node:fs';
import type { IUpload } from '@rocket.chat/core-typings';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import type { ClientSession } from 'mongodb';
import { UploadFS } from './ufs';
export async function ufsComplete(fileId: string, storeName: string, options?: { session?: ClientSession }): Promise<IUpload> {
check(fileId, String);
check(storeName, String);
// Get store
const store = UploadFS.getStore(storeName);
if (!store) {
throw new Meteor.Error('invalid-store', 'Store not found');
}
const tmpFile = UploadFS.getTempFilePath(fileId);
const removeTempFile = () =>
fs.promises.unlink(tmpFile).catch(() => {
console.warn(`[ufsComplete] Failed to remove temp file: ${tmpFile}`);
});
return new Promise(async (resolve, reject) => {
try {
// todo check if temp file exists
// Get file
const file = await store.getCollection().findOne<IUpload>({ _id: fileId }, { session: options?.session });
if (!file) {
throw new Meteor.Error('invalid-file', 'File is not valid');
}
// Validate file before moving to the store
await store.validate(file, { session: options?.session });
// Get the temp file
const rs = fs.createReadStream(tmpFile, {
flags: 'r',
encoding: undefined,
autoClose: true,
});
// Clean upload if error occurs
rs.on('error', (err) => {
console.error(err);
void store.removeById(fileId, { session: options?.session });
reject(err);
});
// Save file in the store
await store.write(
rs,
fileId,
(err, file) => {
void removeTempFile();
if (err) {
return reject(err);
}
if (!file) {
return reject(new Error('Unknown error writing file'));
}
resolve(file);
},
{ session: options?.session },
);
} catch (err: any) {
// If write failed, remove the file
await store.removeById(fileId, { session: options?.session });
reject(new Meteor.Error('ufs: cannot upload file', err));
}
});
}

View File

@@ -0,0 +1,127 @@
/*
* The MIT License (MIT)
*
* Copyright (c) 2017 Karl STEIN
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
/**
* MIME types and extensions
*/
export const MIME: Record<string, string> = {
// application
'7z': 'application/x-7z-compressed',
'arc': 'application/octet-stream',
'ai': 'application/postscript',
'bin': 'application/octet-stream',
'bz': 'application/x-bzip',
'bz2': 'application/x-bzip2',
'eps': 'application/postscript',
'exe': 'application/octet-stream',
'gz': 'application/x-gzip',
'gzip': 'application/x-gzip',
'js': 'application/javascript',
'json': 'application/json',
'ogx': 'application/ogg',
'pdf': 'application/pdf',
'ps': 'application/postscript',
'psd': 'application/octet-stream',
'rar': 'application/x-rar-compressed',
'rev': 'application/x-rar-compressed',
'swf': 'application/x-shockwave-flash',
'tar': 'application/x-tar',
'xhtml': 'application/xhtml+xml',
'xml': 'application/xml',
'zip': 'application/zip',
// audio
'aif': 'audio/aiff',
'aifc': 'audio/aiff',
'aiff': 'audio/aiff',
'au': 'audio/basic',
'flac': 'audio/flac',
'midi': 'audio/midi',
'mp2': 'audio/mpeg',
'mp3': 'audio/mpeg',
'mpa': 'audio/mpeg',
'oga': 'audio/ogg',
'ogg': 'audio/ogg',
'opus': 'audio/ogg',
'ra': 'audio/vnd.rn-realaudio',
'spx': 'audio/ogg',
'wav': 'audio/x-wav',
'weba': 'audio/webm',
'wma': 'audio/x-ms-wma',
// image
'avs': 'image/avs-video',
'bmp': 'image/x-windows-bmp',
'gif': 'image/gif',
'ico': 'image/vnd.microsoft.icon',
'jpeg': 'image/jpeg',
'jpg': 'image/jpg',
'mjpg': 'image/x-motion-jpeg',
'pic': 'image/pic',
'png': 'image/png',
'svg': 'image/svg+xml',
'tif': 'image/tiff',
'tiff': 'image/tiff',
// text
'css': 'text/css',
'csv': 'text/csv',
'html': 'text/html',
'txt': 'text/plain',
// video
'avi': 'video/avi',
'dv': 'video/x-dv',
'flv': 'video/x-flv',
'mov': 'video/quicktime',
'mp4': 'video/mp4',
'mpeg': 'video/mpeg',
'mpg': 'video/mpg',
'ogv': 'video/ogg',
'vdo': 'video/vdo',
'webm': 'video/webm',
'wmv': 'video/x-ms-wmv',
// specific to vendors
'doc': 'application/msword',
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'odb': 'application/vnd.oasis.opendocument.database',
'odc': 'application/vnd.oasis.opendocument.chart',
'odf': 'application/vnd.oasis.opendocument.formula',
'odg': 'application/vnd.oasis.opendocument.graphics',
'odi': 'application/vnd.oasis.opendocument.image',
'odm': 'application/vnd.oasis.opendocument.text-master',
'odp': 'application/vnd.oasis.opendocument.presentation',
'ods': 'application/vnd.oasis.opendocument.spreadsheet',
'odt': 'application/vnd.oasis.opendocument.text',
'otg': 'application/vnd.oasis.opendocument.graphics-template',
'otp': 'application/vnd.oasis.opendocument.presentation-template',
'ots': 'application/vnd.oasis.opendocument.spreadsheet-template',
'ott': 'application/vnd.oasis.opendocument.text-template',
'ppt': 'application/vnd.ms-powerpoint',
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'xls': 'application/vnd.ms-excel',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};

View File

@@ -0,0 +1,295 @@
import fs from 'node:fs';
import stream from 'node:stream';
import { pipeline } from 'node:stream/promises';
import URL from 'node:url';
import zlib from 'node:zlib';
import { Meteor } from 'meteor/meteor';
import { WebApp } from 'meteor/webapp';
import mkdirp from 'mkdirp';
import { UploadFS } from './ufs';
Meteor.startup(() => {
const path = UploadFS.config.tmpDir;
const mode = UploadFS.config.tmpDirPermissions;
fs.stat(path, (err) => {
if (err) {
// Create the temp directory
mkdirp(path, { mode })
.then(() => {
console.log(`ufs: temp directory created at "${path}"`);
})
.catch((err) => {
console.error(`ufs: cannot create temp directory at "${path}" (${err.message})`);
});
} else {
// Set directory permissions
fs.chmod(path, mode, (err) => {
err && console.error(`ufs: cannot set temp directory permissions ${mode} (${err.message})`);
});
}
});
});
// Listen HTTP requests to serve files
WebApp.connectHandlers.use(async (req, res, next) => {
// Quick check to see if request should be caught
if (!req.url?.includes(`/${UploadFS.config.storesPath}/`)) {
next();
return;
}
// Remove store path
const parsedUrl = URL.parse(req.url, true);
const path = parsedUrl.pathname?.substr(UploadFS.config.storesPath.length + 1);
if (!path) {
next();
return;
}
const allowCORS = () => {
// res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
res.setHeader('Access-Control-Allow-Methods', 'POST');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
};
if (req.method === 'OPTIONS') {
const regExp = new RegExp('^/([^/?]+)/([^/?]+)$');
const match = regExp.exec(path);
// Request is not valid
if (match === null) {
res.writeHead(400);
res.end();
return;
}
// Get store
const store = UploadFS.getStore(match[1]);
if (!store) {
res.writeHead(404);
res.end();
return;
}
// If a store is found, go ahead and allow the origin
allowCORS();
next();
} else if (req.method === 'POST') {
res.writeHead(404);
res.end();
} else if (req.method === 'GET') {
// Get store, file Id and file name
const regExp = new RegExp('^/([^/?]+)/([^/?]+)(?:/([^/?]+))?$');
const match = regExp.exec(path);
// Avoid 504 Gateway timeout error
// if file is not handled by UploadFS.
if (match === null) {
next();
return;
}
// Get store
const storeName = match[1];
const store = UploadFS.getStore(storeName);
if (!store) {
res.writeHead(404);
res.end();
return;
}
if (store.onRead !== null && store.onRead !== undefined && typeof store.onRead !== 'function') {
console.error(`ufs: Store.onRead is not a function in store "${storeName}"`);
res.writeHead(500);
res.end();
return;
}
// Remove file extension from file Id
const index = match[2].indexOf('.');
const fileId = index !== -1 ? match[2].substr(0, index) : match[2];
// Get file from database
const file = await store.getCollection().findOne({ _id: fileId });
if (!file) {
res.writeHead(404);
res.end();
return;
}
try {
// Check if the file can be accessed
if ((await store.onRead.call(store, fileId, file, req, res)) !== false) {
const options: {
start?: number;
end?: number;
} = {};
let status = 200;
// Prepare response headers
const headers: Record<string, any> = {
'Content-Type': file.type,
'Content-Length': file.size,
};
// Add ETag header
if (typeof file.etag === 'string') {
headers.ETag = file.etag;
}
// Add Last-Modified header
if (file.modifiedAt instanceof Date) {
headers['Last-Modified'] = file.modifiedAt.toUTCString();
} else if (file.uploadedAt instanceof Date) {
headers['Last-Modified'] = file.uploadedAt.toUTCString();
}
// Parse request headers
if (typeof req.headers === 'object') {
// Compare ETag
if (req.headers['if-none-match']) {
if (file.etag === req.headers['if-none-match']) {
res.writeHead(304); // Not Modified
res.end();
return;
}
}
// Compare file modification date
if (req.headers['if-modified-since']) {
const modifiedSince = new Date(req.headers['if-modified-since']);
if (
(file.modifiedAt instanceof Date && file.modifiedAt > modifiedSince) ||
(file.uploadedAt instanceof Date && file.uploadedAt > modifiedSince)
) {
res.writeHead(304); // Not Modified
res.end();
return;
}
}
// Support range request
if (typeof req.headers.range === 'string') {
const { range } = req.headers;
// Range is not valid
if (!range) {
res.writeHead(416);
res.end();
return;
}
const total = file.size || 0;
const unit = range.substr(0, range.indexOf('='));
if (unit !== 'bytes') {
res.writeHead(416);
res.end();
return;
}
const ranges = range
.substr(unit.length)
.replace(/[^0-9\-,]/, '')
.split(',');
if (ranges.length > 1) {
// todo: support multipart ranges: https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests
} else {
const r = ranges[0].split('-');
const start = parseInt(r[0], 10);
const end = r[1] ? parseInt(r[1], 10) : total - 1;
// Range is not valid
if (start < 0 || end >= total || start > end) {
res.writeHead(416);
res.end();
return;
}
// Update headers
headers['Content-Range'] = `bytes ${start}-${end}/${total}`;
headers['Content-Length'] = end - start + 1;
options.start = start;
options.end = end;
}
status = 206; // partial content
}
} else {
headers['Accept-Ranges'] = 'bytes';
}
// Open the file stream
const rs = await store.getReadStream(fileId, file, options);
const ws = new stream.PassThrough();
rs.on('error', (err) => {
store.onReadError.call(store, err, fileId, file);
res.end();
});
ws.on('error', (err) => {
store.onReadError.call(store, err, fileId, file);
res.end();
});
ws.on('close', () => {
// Close output stream at the end
ws.emit('end');
});
// Transform stream
store.transformRead(rs, ws, fileId, file, req, headers);
// Parse request headers
if (typeof req.headers === 'object') {
// Compress data using if needed (ignore audio/video as they are already compressed)
if (typeof req.headers['accept-encoding'] === 'string' && (!file.type || !/^(audio|video)/.test(file.type))) {
const accept = req.headers['accept-encoding'];
// Compress with gzip
if (accept.match(/\bgzip\b/)) {
headers['Content-Encoding'] = 'gzip';
delete headers['Content-Length'];
res.writeHead(status, headers);
await pipeline(ws, zlib.createGzip(), res);
return;
}
// Compress with deflate
if (accept.match(/\bdeflate\b/)) {
headers['Content-Encoding'] = 'deflate';
delete headers['Content-Length'];
res.writeHead(status, headers);
await pipeline(ws, zlib.createDeflate(), res);
return;
}
}
}
// Send raw data
if (!headers['Content-Encoding']) {
res.writeHead(status, headers);
await pipeline(ws, res);
}
} else {
res.end();
}
} catch (err) {
console.error(`ufs: ${err instanceof Error ? err.message : String(err)}`);
if (!res.headersSent) {
res.writeHead(500);
}
if (!res.writableEnded) {
res.end();
}
}
} else {
next();
}
});

View File

@@ -0,0 +1,360 @@
import fs from 'node:fs';
import type * as http from 'node:http';
import type stream from 'node:stream';
import type { IUpload } from '@rocket.chat/core-typings';
import type { IBaseUploadsModel } from '@rocket.chat/model-typings';
import type createServer from 'connect';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import type { ClientSession, OptionalId } from 'mongodb';
import { UploadFS } from '.';
import { Filter } from './ufs-filter';
export type StoreOptions = {
collection?: IBaseUploadsModel<IUpload>;
filter?: Filter;
name: string;
onCopyError?: (err: any, fileId: string, file: IUpload) => void;
onFinishUpload?: (file: IUpload) => Promise<void>;
onRead?: (fileId: string, file: IUpload, request: any, response: any) => Promise<boolean>;
onReadError?: (err: any, fileId: string, file: IUpload) => void;
onValidate?: (file: IUpload, options?: { session?: ClientSession }) => Promise<void>;
onWriteError?: (err: any, fileId: string, file: IUpload) => void;
transformRead?: (
readStream: stream.Readable,
writeStream: stream.Writable,
fileId: string,
file: IUpload,
request: createServer.IncomingMessage,
headers?: Record<string, any>,
) => void;
transformWrite?: (readStream: stream.Readable, writeStream: stream.Writable, fileId: string, file: IUpload) => void;
};
export class Store {
protected options: StoreOptions;
public copy: (
fileId: string,
store: Store,
callback?: (err?: Error, copyId?: string, copy?: OptionalId<IUpload>, store?: Store) => void,
) => Promise<void>;
public create: (file: Omit<OptionalId<IUpload>, '_updatedAt'>, options?: { session?: ClientSession }) => Promise<string>;
public write: (
rs: stream.Readable,
fileId: string,
callback: (err?: Error, file?: IUpload) => void,
options?: { session?: ClientSession },
) => Promise<void>;
constructor(options: StoreOptions) {
options = {
onCopyError: this.onCopyError,
onFinishUpload: this.onFinishUpload,
onRead: this.onRead,
onReadError: this.onReadError,
onValidate: this.onValidate,
onWriteError: this.onWriteError,
...options,
};
if (UploadFS.getStore(options.name)) {
throw new TypeError('Store: name already exists');
}
if (options.onCopyError && typeof options.onCopyError !== 'function') {
throw new TypeError('Store: onCopyError is not a function');
}
if (options.onFinishUpload && typeof options.onFinishUpload !== 'function') {
throw new TypeError('Store: onFinishUpload is not a function');
}
if (options.onRead && typeof options.onRead !== 'function') {
throw new TypeError('Store: onRead is not a function');
}
if (options.onReadError && typeof options.onReadError !== 'function') {
throw new TypeError('Store: onReadError is not a function');
}
if (options.onWriteError && typeof options.onWriteError !== 'function') {
throw new TypeError('Store: onWriteError is not a function');
}
if (options.transformRead && typeof options.transformRead !== 'function') {
throw new TypeError('Store: transformRead is not a function');
}
if (options.transformWrite && typeof options.transformWrite !== 'function') {
throw new TypeError('Store: transformWrite is not a function');
}
if (options.onValidate && typeof options.onValidate !== 'function') {
throw new TypeError('Store: onValidate is not a function');
}
// Public attributes
this.options = options;
if (options.onCopyError) this.onCopyError = options.onCopyError;
if (options.onFinishUpload) this.onFinishUpload = options.onFinishUpload;
if (options.onRead) this.onRead = options.onRead;
if (options.onReadError) this.onReadError = options.onReadError;
if (options.onWriteError) this.onWriteError = options.onWriteError;
if (options.onValidate) this.onValidate = options.onValidate;
// Add the store to the list
UploadFS.addStore(this);
this.copy = async (fileId, store, callback) => {
check(fileId, String);
if (!(store instanceof Store)) {
throw new TypeError('store is not an instance of UploadFS.Store');
}
// Get original file
const file = await this.getCollection().findOne({ _id: fileId });
if (!file) {
throw new Meteor.Error('file-not-found', 'File not found');
}
// Silently ignore the file if it does not match filter
const filter = store.getFilter();
if (filter instanceof Filter && !(await filter.isValid(file))) {
return;
}
// Prepare copy
const { _id, url, ...copy } = file;
copy.originalStore = this.getName();
copy.originalId = fileId;
// Create the copy
const copyId = await store.create(copy);
// Get original stream
const rs = await this.getReadStream(fileId, file);
// Catch errors to avoid app crashing
rs.on('error', (err: Error) => {
callback?.call(this, err);
});
// Copy file data
await store.write(rs, copyId, (err) => {
if (err) {
void this.removeById(copyId);
this.onCopyError.call(this, err, fileId, file);
}
if (typeof callback === 'function') {
callback.call(this, err, copyId, copy, store);
}
});
};
this.create = async (file, options) => {
check(file, Object);
file.store = this.options.name; // assign store to file
return (await this.getCollection().insertOne(file, { session: options?.session })).insertedId;
};
this.write = async (rs, fileId, callback, options) => {
const file = await this.getCollection().findOne({ _id: fileId }, { session: options?.session });
if (!file) {
return callback(new Error('File not found'));
}
const errorHandler = (err: Error) => {
this.onWriteError.call(this, err, fileId, file);
callback.call(this, err);
};
const finishHandler = async () => {
try {
// Set file attribute
file.complete = true;
file.etag = UploadFS.generateEtag();
file.path = await this.getFileRelativeURL(fileId);
file.progress = 1;
file.token = this.generateToken();
file.uploading = false;
file.uploadedAt = new Date();
file.url = await this.getFileURL(fileId);
// Execute callback
if (typeof this.onFinishUpload === 'function') {
await this.onFinishUpload.call(this, file);
}
// Sets the file URL when file transfer is complete,
// this way, the image will loads entirely.
await this.getCollection().updateOne(
{ _id: fileId },
{
$set: {
complete: file.complete,
etag: file.etag,
path: file.path,
progress: file.progress,
size: file.size,
token: file.token,
uploading: file.uploading,
uploadedAt: file.uploadedAt,
url: file.url,
},
},
{ session: options?.session },
);
// Return file info
callback.call(this, undefined, file);
} catch (error) {
callback.call(this, error as Error);
}
};
const ws = await this.getWriteStream(fileId, file);
ws.on('error', errorHandler);
ws.once('finish', finishHandler);
// Execute transformation
this.transformWrite(rs, ws, fileId, file);
};
}
async removeById(fileId: string, options?: { session?: ClientSession }, isDeleted = false): Promise<void> {
// Delete the physical file in the store
if (!isDeleted) {
await this.delete(fileId);
}
const tmpFile = UploadFS.getTempFilePath(fileId);
// Delete the temp file
await fs.promises.unlink(tmpFile).catch((err) => {
err?.code !== 'ENOENT' && console.error(`ufs: cannot delete temp file at ${tmpFile} (${err.message})`);
});
await this.getCollection().removeById(fileId, { session: options?.session });
}
async delete(_fileId: string, _options?: { session?: ClientSession }): Promise<any> {
throw new Error('delete is not implemented');
}
generateToken(pattern?: string) {
return (pattern || 'xyxyxyxyxy').replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
const s = v.toString(16);
return Math.round(Math.random()) ? s.toUpperCase() : s;
});
}
getCollection() {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return this.options.collection!;
}
async getFilePath(_fileId: string, _file?: IUpload): Promise<string> {
throw new Error('Store.getFilePath is not implemented');
}
async getFileRelativeURL(fileId: string) {
const file = await this.getCollection().findOne(fileId, { projection: { name: 1 } });
return file ? this.getRelativeURL(`${fileId}/${file.name}`) : undefined;
}
async getFileURL(fileId: string) {
const file = await this.getCollection().findOne(fileId, { projection: { name: 1 } });
return file ? this.getURL(`${fileId}/${file.name}`) : undefined;
}
getFilter() {
return this.options.filter;
}
getName() {
return this.options.name;
}
async getReadStream(_fileId: string, _file: IUpload, _options?: { start?: number; end?: number }): Promise<stream.Readable> {
throw new Error('Store.getReadStream is not implemented');
}
getRelativeURL(path: string) {
const rootUrl = Meteor.absoluteUrl().replace(/\/+$/, '');
const rootPath = rootUrl.replace(/^[a-z]+:\/\/[^/]+\/*/gi, '');
const storeName = this.getName();
path = String(path).replace(/\/$/, '').trim();
return encodeURI(`${rootPath}/${UploadFS.config.storesPath}/${storeName}/${path}`);
}
getURL(path: string) {
const rootUrl = Meteor.absoluteUrl('', { secure: UploadFS.config.https }).replace(/\/+$/, '');
const storeName = this.getName();
path = String(path).replace(/\/$/, '').trim();
return encodeURI(`${rootUrl}/${UploadFS.config.storesPath}/${storeName}/${path}`);
}
async getRedirectURL(_file: IUpload, _forceDownload = false): Promise<string> {
throw new Error('getRedirectURL is not implemented');
}
async getWriteStream(_fileId: string, _file: IUpload): Promise<stream.Writable> {
throw new Error('getWriteStream is not implemented');
}
onCopyError(err: Error, fileId: string, _file: IUpload) {
console.error(`ufs: cannot copy file "${fileId}" (${err.message})`, err);
}
async onFinishUpload(_file: IUpload) {
//
}
async onRead(_fileId: string, _file: IUpload, _request: createServer.IncomingMessage, _response: http.ServerResponse) {
return true;
}
onReadError(err: Error, fileId: string, _file: IUpload) {
console.error(`ufs: cannot read file "${fileId}" (${err.message})`, err);
}
async onValidate(_file: IUpload, _options?: { session?: ClientSession }) {
//
}
onWriteError(err: Error, fileId: string, _file: IUpload) {
console.error(`ufs: cannot write file "${fileId}" (${err.message})`, err);
}
transformRead(
readStream: stream.Readable,
writeStream: stream.Writable,
fileId: string,
file: IUpload,
request: createServer.IncomingMessage,
headers?: Record<string, any>,
) {
if (typeof this.options.transformRead === 'function') {
this.options.transformRead.call(this, readStream, writeStream, fileId, file, request, headers);
} else {
readStream.pipe(writeStream);
}
}
transformWrite(readStream: stream.Readable, writeStream: stream.Writable, fileId: string, file: IUpload) {
if (typeof this.options.transformWrite === 'function') {
this.options.transformWrite.call(this, readStream, writeStream, fileId, file);
} else {
readStream.pipe(writeStream);
}
}
async validate(file: IUpload, options?: { session?: ClientSession }) {
if (typeof this.onValidate === 'function') {
await this.onValidate(file, options);
}
}
async getUrlExpiryTimeSpan(): Promise<number | null> {
throw new Error('getUrlExpiryTimeSpan is not implemented');
}
}

View File

@@ -0,0 +1,51 @@
import { Random } from '@rocket.chat/random';
import { Config } from './ufs-config';
import { Filter } from './ufs-filter';
import { MIME } from './ufs-mime';
import { Store } from './ufs-store';
const stores: Record<string, Store> = {};
const store: Record<string, typeof Store> = {};
export const UploadFS = {
config: new Config(),
store,
addStore(store: Store) {
if (!(store instanceof Store)) {
throw new TypeError('ufs: store is not an instance of UploadFS.Store.');
}
stores[store.getName()] = store;
},
generateEtag() {
return Random.id();
},
getMimeType(extension: string) {
extension = extension.toLowerCase();
return MIME[extension];
},
getMimeTypes() {
return MIME;
},
getStore(name: string) {
return stores[name];
},
getStores() {
return stores;
},
getTempFilePath(fileId: string) {
return `${this.config.tmpDir}/${fileId}`;
},
Config,
Filter,
Store,
};