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
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:
7
apps/meteor/server/ufs/index.ts
Normal file
7
apps/meteor/server/ufs/index.ts
Normal 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;
|
||||
56
apps/meteor/server/ufs/ufs-config.ts
Normal file
56
apps/meteor/server/ufs/ufs-config.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
143
apps/meteor/server/ufs/ufs-filter.ts
Normal file
143
apps/meteor/server/ufs/ufs-filter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
92
apps/meteor/server/ufs/ufs-gridfs.ts
Normal file
92
apps/meteor/server/ufs/ufs-gridfs.ts
Normal 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;
|
||||
115
apps/meteor/server/ufs/ufs-local.spec.ts
Normal file
115
apps/meteor/server/ufs/ufs-local.spec.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
111
apps/meteor/server/ufs/ufs-local.ts
Normal file
111
apps/meteor/server/ufs/ufs-local.ts
Normal 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;
|
||||
78
apps/meteor/server/ufs/ufs-methods.ts
Normal file
78
apps/meteor/server/ufs/ufs-methods.ts
Normal 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));
|
||||
}
|
||||
});
|
||||
}
|
||||
127
apps/meteor/server/ufs/ufs-mime.ts
Normal file
127
apps/meteor/server/ufs/ufs-mime.ts
Normal 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',
|
||||
};
|
||||
295
apps/meteor/server/ufs/ufs-server.ts
Normal file
295
apps/meteor/server/ufs/ufs-server.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
360
apps/meteor/server/ufs/ufs-store.ts
Normal file
360
apps/meteor/server/ufs/ufs-store.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
51
apps/meteor/server/ufs/ufs.ts
Normal file
51
apps/meteor/server/ufs/ufs.ts
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user