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,480 @@
import crypto from 'node:crypto';
import type { ServerResponse, IncomingMessage } from 'node:http';
import type { IRocketChatAssets, IRocketChatAsset, ISetting } from '@rocket.chat/core-typings';
import { Settings } from '@rocket.chat/models';
import type { NextHandleFunction } from 'connect';
import sizeOf from 'image-size';
import { Meteor } from 'meteor/meteor';
import { WebApp, WebAppInternals } from 'meteor/webapp';
import sharp from 'sharp';
import { getExtension } from '../../../../app/utils/lib/mimeTypes';
import { settings, settingsRegistry } from '../../../settings';
import { hasPermissionAsync } from '../../authorization/hasPermission';
import { notifyOnSettingChangedById } from '../../notifyListener';
import { getURL } from '../../utils/getURL';
import { RocketChatFile } from '../file';
const RocketChatAssetsInstance = new RocketChatFile.GridFS({
name: 'assets',
});
type IRocketChatAssetsConfig = Record<keyof IRocketChatAssets, IRocketChatAsset & { settingOptions?: Partial<ISetting> }>;
const assets: IRocketChatAssetsConfig = {
logo: {
label: 'logo (svg, png, jpg)',
defaultUrl: 'images/logo/logo.svg',
constraints: {
type: 'image',
extensions: ['svg', 'png', 'jpg', 'jpeg'],
},
wizard: {
step: 3,
order: 2,
},
},
logo_dark: {
label: 'logo - dark theme (svg, png, jpg)',
defaultUrl: 'images/logo/logo_dark.svg',
constraints: {
type: 'image',
extensions: ['svg', 'png', 'jpg', 'jpeg'],
},
},
background: {
label: 'login background (svg, png, jpg)',
constraints: {
type: 'image',
extensions: ['svg', 'png', 'jpg', 'jpeg'],
},
},
background_dark: {
label: 'login background - dark theme (svg, png, jpg)',
constraints: {
type: 'image',
extensions: ['svg', 'png', 'jpg', 'jpeg'],
},
},
favicon_ico: {
label: 'favicon (ico)',
defaultUrl: 'favicon.ico',
constraints: {
type: 'image',
extensions: ['ico'],
},
},
favicon: {
label: 'favicon (svg)',
defaultUrl: 'images/logo/icon.svg',
constraints: {
type: 'image',
extensions: ['svg'],
},
},
favicon_16: {
label: 'favicon 16x16 (png)',
defaultUrl: 'images/logo/favicon-16x16.png',
constraints: {
type: 'image',
extensions: ['png'],
width: 16,
height: 16,
},
},
favicon_32: {
label: 'favicon 32x32 (png)',
defaultUrl: 'images/logo/favicon-32x32.png',
constraints: {
type: 'image',
extensions: ['png'],
width: 32,
height: 32,
},
},
favicon_192: {
label: 'android-chrome 192x192 (png)',
defaultUrl: 'images/logo/android-chrome-192x192.png',
constraints: {
type: 'image',
extensions: ['png'],
width: 192,
height: 192,
},
},
favicon_512: {
label: 'android-chrome 512x512 (png)',
defaultUrl: 'images/logo/android-chrome-512x512.png',
constraints: {
type: 'image',
extensions: ['png'],
width: 512,
height: 512,
},
},
touchicon_180: {
label: 'apple-touch-icon 180x180 (png)',
defaultUrl: 'images/logo/apple-touch-icon.png',
constraints: {
type: 'image',
extensions: ['png'],
width: 180,
height: 180,
},
},
touchicon_180_pre: {
label: 'apple-touch-icon-precomposed 180x180 (png)',
defaultUrl: 'images/logo/apple-touch-icon-precomposed.png',
constraints: {
type: 'image',
extensions: ['png'],
width: 180,
height: 180,
},
},
tile_70: {
label: 'mstile 70x70 (png)',
defaultUrl: 'images/logo/mstile-70x70.png',
constraints: {
type: 'image',
extensions: ['png'],
width: 70,
height: 70,
},
},
tile_144: {
label: 'mstile 144x144 (png)',
defaultUrl: 'images/logo/mstile-144x144.png',
constraints: {
type: 'image',
extensions: ['png'],
width: 144,
height: 144,
},
},
tile_150: {
label: 'mstile 150x150 (png)',
defaultUrl: 'images/logo/mstile-150x150.png',
constraints: {
type: 'image',
extensions: ['png'],
width: 150,
height: 150,
},
},
tile_310_square: {
label: 'mstile 310x310 (png)',
defaultUrl: 'images/logo/mstile-310x310.png',
constraints: {
type: 'image',
extensions: ['png'],
width: 310,
height: 310,
},
},
tile_310_wide: {
label: 'mstile 310x150 (png)',
defaultUrl: 'images/logo/mstile-310x150.png',
constraints: {
type: 'image',
extensions: ['png'],
width: 310,
height: 150,
},
},
safari_pinned: {
label: 'safari pinned tab (svg)',
defaultUrl: 'images/logo/safari-pinned-tab.svg',
constraints: {
type: 'image',
extensions: ['svg'],
},
},
livechat_widget_logo: {
label: 'widget logo (svg, png, jpg)',
constraints: {
type: 'image',
extensions: ['svg', 'png', 'jpg', 'jpeg'],
},
settingOptions: {
section: 'Livechat',
group: 'Omnichannel',
invalidValue: {
defaultUrl: undefined,
},
enableQuery: { _id: 'Livechat_enabled', value: true },
enterprise: true,
modules: ['livechat-enterprise'],
sorter: 999 + 1,
},
},
};
function getAssetByKey(key: string) {
return assets[key as keyof IRocketChatAssets];
}
class RocketChatAssetsClass {
get assets(): IRocketChatAssets {
return assets;
}
public async setAssetWithBuffer(
file: Buffer,
contentType: string,
asset: string,
): Promise<{
key: string;
value: IRocketChatAsset;
}> {
const assetInstance = getAssetByKey(asset);
if (!assetInstance) {
throw new Meteor.Error('error-invalid-asset', 'Invalid asset', {
function: 'RocketChat.Assets.setAsset',
});
}
const extension = getExtension(contentType);
if (assetInstance.constraints.extensions.includes(extension) === false) {
throw new Meteor.Error('error-invalid-file-type', `Invalid file type: ${contentType}`, {
function: 'RocketChat.Assets.setAsset',
});
}
if (assetInstance.constraints.width || assetInstance.constraints.height) {
const dimensions = sizeOf(file);
if (assetInstance.constraints.width && assetInstance.constraints.width !== dimensions.width) {
throw new Meteor.Error('error-invalid-file-width', 'Invalid file width', {
function: 'Invalid file width',
});
}
if (assetInstance.constraints.height && assetInstance.constraints.height !== dimensions.height) {
throw new Meteor.Error('error-invalid-file-height');
}
}
const rs = RocketChatFile.bufferToStream(file);
await RocketChatAssetsInstance.deleteFile(asset);
const ws = RocketChatAssetsInstance.createWriteStream(asset, contentType);
return new Promise((resolve) => {
ws.on('end', () => {
return setTimeout(async () => {
const key = `Assets_${asset}`;
const value = {
url: `assets/${asset}.${extension}`,
defaultUrl: assetInstance.defaultUrl,
} as IRocketChatAsset;
await RocketChatAssets.processAsset(key, value);
resolve({
key,
value,
});
}, 200);
});
rs.pipe(ws);
});
}
public async unsetAsset(asset: string) {
if (!getAssetByKey(asset)) {
throw new Meteor.Error('error-invalid-asset', 'Invalid asset', {
function: 'RocketChat.Assets.unsetAsset',
});
}
await RocketChatAssetsInstance.deleteFile(asset);
const key = `Assets_${asset}`;
const value = {
defaultUrl: getAssetByKey(asset).defaultUrl,
};
await RocketChatAssets.processAsset(key, value);
return {
key,
value,
};
}
public refreshClients(): boolean {
return process.emit('message', {
refresh: 'client',
});
}
public async processAsset(settingKey: string, settingValue: any): Promise<Record<string, any> | undefined> {
if (settingKey.indexOf('Assets_') !== 0) {
return;
}
const assetKey = settingKey.replace(/^Assets_/, '');
const assetValue = getAssetByKey(assetKey);
if (!assetValue) {
return;
}
if (!settingValue?.url) {
assetValue.cache = undefined;
return;
}
const file = await RocketChatAssetsInstance.getFile(assetKey);
if (!file) {
assetValue.cache = undefined;
return;
}
const hash = crypto.createHash('sha1').update(file.buffer).digest('hex');
const extension = settingValue.url.split('.').pop();
assetValue.cache = {
path: `assets/${assetKey}.${extension}`,
cacheable: false,
sourceMapUrl: undefined,
where: 'client',
type: 'asset',
content: file.buffer,
extension,
url: `/assets/${assetKey}.${extension}?${hash}`,
size: file.length,
uploadDate: file.uploadDate,
contentType: file.contentType,
hash,
};
return assetValue.cache;
}
public getURL(assetName: string, options = { cdn: false, full: true }): string {
const asset = settings.get<IRocketChatAsset>(assetName);
const url = asset.url || asset.defaultUrl;
return getURL(url as string, options);
}
}
export const RocketChatAssets = new RocketChatAssetsClass();
export async function addAssetToSetting(asset: string, value: IRocketChatAsset, options?: Partial<ISetting>): Promise<void> {
const key = `Assets_${asset}`;
await settingsRegistry.add(
key,
{
defaultUrl: value.defaultUrl,
},
{
type: 'asset',
group: 'Assets',
fileConstraints: value.constraints,
i18nLabel: value.label,
asset,
public: true,
...options,
},
);
const currentValue = settings.get<IRocketChatAsset>(key);
if (currentValue && typeof currentValue === 'object' && currentValue.defaultUrl !== getAssetByKey(asset).defaultUrl) {
currentValue.defaultUrl = getAssetByKey(asset).defaultUrl;
(await Settings.updateValueById(key, currentValue)).modifiedCount && void notifyOnSettingChangedById(key);
}
}
void (async () => {
for (const key of Object.keys(assets)) {
const { wizard, settingOptions, ...value } = getAssetByKey(key);
await addAssetToSetting(key, value, { ...settingOptions, wizard });
}
})();
Meteor.startup(() => {
setTimeout(() => {
process.emit('message', {
refresh: 'client',
});
}, 200);
});
export const refreshClients = async (userId: string) => {
if (!userId) {
throw new Error('Invalid user');
}
const _hasPermission = await hasPermissionAsync(userId, 'manage-assets');
if (!_hasPermission) {
throw new Error('Managing assets not allowed');
}
return RocketChatAssets.refreshClients();
};
const listener = (req: IncomingMessage, res: ServerResponse, next: NextHandleFunction) => {
if (!req.url) {
return;
}
const params = {
asset: decodeURIComponent(req.url.replace(/^\//, '').replace(/\?.*$/, '')).replace(/\.[^.]*$/, ''),
};
const asset = getAssetByKey(params.asset);
const file = asset?.cache;
const format = req.url.replace(/.*\.([a-z]+)(?:$|\?.*)/i, '$1');
if (asset && Array.isArray(asset.constraints.extensions) && !asset.constraints.extensions.includes(format)) {
res.writeHead(403);
res.end();
return;
}
if (!file) {
const defaultUrl = asset?.defaultUrl;
if (defaultUrl) {
const assetUrl = format && ['png', 'svg'].includes(format) ? defaultUrl.replace(/(svg|png)$/, format) : defaultUrl;
req.url = `/${assetUrl}`;
WebAppInternals.staticFilesMiddleware((WebAppInternals as Record<string, any>).staticFilesByArch, req, res, next);
} else {
res.writeHead(404);
res.end();
}
return;
}
const reqModifiedHeader = req.headers['if-modified-since'];
if (reqModifiedHeader) {
if (reqModifiedHeader === file.uploadDate?.toUTCString()) {
res.setHeader('Last-Modified', reqModifiedHeader);
res.writeHead(304);
res.end();
return;
}
}
res.setHeader('Cache-Control', 'public, max-age=0');
res.setHeader('Expires', '-1');
if (format && format !== file.extension && ['png', 'jpg', 'jpeg'].includes(format)) {
res.setHeader('Content-Type', `image/${format}`);
sharp(file.content)
.toFormat(format as any)
.pipe(res);
return;
}
res.setHeader('Last-Modified', file.uploadDate?.toUTCString() || new Date().toUTCString());
if (file.contentType) res.setHeader('Content-Type', file.contentType);
if (file.size) res.setHeader('Content-Length', file.size);
res.writeHead(200);
res.end(file.content);
};
WebApp.connectHandlers.use('/assets/', listener);

View File

@@ -0,0 +1 @@
export { RocketChatAssets, refreshClients } from './assets';

View File

@@ -0,0 +1,20 @@
import { api } from '@rocket.chat/core-services';
import { CustomSounds } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { RocketChatFileCustomSoundsInstance } from '../startup/custom-sounds';
export const deleteCustomSound = async (_id: string): Promise<void> => {
const sound = await CustomSounds.findOneById(_id);
if (!sound) {
throw new Meteor.Error('Custom_Sound_Error_Invalid_Sound', 'Invalid sound', {
method: 'deleteCustomSound',
});
}
await RocketChatFileCustomSoundsInstance.deleteFile(`${sound._id}.${sound.extension}`);
await CustomSounds.removeById(_id);
void api.broadcast('notify.deleteCustomSound', { soundData: sound });
};

View File

@@ -0,0 +1,61 @@
import { api } from '@rocket.chat/core-services';
import { CustomSounds } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import type { ICustomSoundData } from '../../../../meteor-methods/media/insertOrUpdateSound';
import { RocketChatFileCustomSoundsInstance } from '../startup/custom-sounds';
export const insertOrUpdateSound = async (soundData: ICustomSoundData): Promise<string> => {
// silently strip colon; this allows for uploading :soundname: as soundname
soundData.name = (soundData.name || '').replace(/:/g, '');
if (!soundData.name.trim()) {
throw new Meteor.Error('error-the-field-is-required', 'The field Name is required', {
method: 'insertOrUpdateSound',
field: 'Name',
});
}
// allow all characters except colon, whitespace, comma, >, <, &, ", ', /, \, (, )
// more practical than allowing specific sets of characters; also allows foreign languages
const nameValidation = /[\s,:><&"'\/\\\(\)]/;
if (nameValidation.test(soundData.name)) {
throw new Meteor.Error('error-input-is-not-a-valid-field', `${soundData.name} is not a valid name`, {
method: 'insertOrUpdateSound',
input: soundData.name,
field: 'Name',
});
}
const conflictingSound = await CustomSounds.findOneByName(soundData.name, soundData._id, { projection: { _id: 1 } });
if (conflictingSound) {
throw new Meteor.Error('Custom_Sound_Error_Name_Already_In_Use', 'The custom sound name is already in use', {
method: 'insertOrUpdateSound',
});
}
if (!soundData._id) {
return (
await CustomSounds.create({
name: soundData.name,
extension: soundData.extension,
})
).insertedId;
}
if (soundData.newFile) {
await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.previousExtension}`);
}
if (soundData.name !== soundData.previousName || soundData.extension !== soundData.previousExtension) {
await CustomSounds.updateById(soundData._id, { name: soundData.name, extension: soundData.extension });
const updatedSound = await CustomSounds.findOneById(soundData._id);
if (updatedSound) {
void api.broadcast('notify.updateCustomSound', { soundData: updatedSound });
}
}
return soundData._id;
};

View File

@@ -0,0 +1,44 @@
import { api } from '@rocket.chat/core-services';
import type { RequiredField } from '@rocket.chat/core-typings';
import { CustomSounds } from '@rocket.chat/models';
import type { ICustomSoundData } from '../../../../meteor-methods/media/insertOrUpdateSound';
import { RocketChatFile } from '../../file';
import { RocketChatFileCustomSoundsInstance } from '../startup/custom-sounds';
export const uploadCustomSound = async (
buffer: Buffer,
contentType: string,
soundData: RequiredField<ICustomSoundData, '_id'>,
): Promise<void> => {
const rs = RocketChatFile.bufferToStream(buffer);
if (soundData.previousExtension) {
await RocketChatFileCustomSoundsInstance.deleteFile(`${soundData._id}.${soundData.previousExtension}`);
}
return new Promise((resolve, reject) => {
const ws = RocketChatFileCustomSoundsInstance.createWriteStream(`${soundData._id}.${soundData.extension}`, contentType);
ws.on('error', (err: Error) => {
reject(err);
});
rs.on('error', (err: Error) => {
ws.destroy();
reject(err);
});
ws.on('end', () => {
setTimeout(async () => {
const sound = await CustomSounds.findOneById(soundData._id);
if (sound) {
void api.broadcast('notify.updateCustomSound', { soundData: sound });
}
}, 500);
resolve();
});
rs.pipe(ws);
});
};

View File

@@ -0,0 +1,104 @@
import { CustomSounds } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { WebApp } from 'meteor/webapp';
import { settings } from '../../../../settings';
import { SystemLogger } from '../../../logger/system';
import { RocketChatFile } from '../../file';
export let RocketChatFileCustomSoundsInstance;
const initializeCustomSoundsStorage = () => {
let storeType = 'GridFS';
if (settings.get('CustomSounds_Storage_Type')) {
storeType = settings.get('CustomSounds_Storage_Type');
}
const RocketChatStore = RocketChatFile[storeType];
if (RocketChatStore == null) {
throw new Error(`Invalid RocketChatStore type [${storeType}]`);
}
SystemLogger.info({
msg: 'Using custom sounds storage',
storeType,
});
let path = '~/uploads';
if (settings.get('CustomSounds_FileSystemPath') != null) {
if (settings.get('CustomSounds_FileSystemPath').trim() !== '') {
path = settings.get('CustomSounds_FileSystemPath');
}
}
RocketChatFileCustomSoundsInstance = new RocketChatStore({
name: 'custom_sounds',
absolutePath: path,
});
};
Meteor.startup(() => {
initializeCustomSoundsStorage();
return WebApp.connectHandlers.use('/custom-sounds/', async (req, res /* , next*/) => {
const fileId = decodeURIComponent(req.url.replace(/^\//, '').replace(/\?.*$/, ''));
if (!fileId) {
res.writeHead(403);
res.write('Forbidden');
res.end();
return;
}
const sound = await CustomSounds.findOneById(fileId.split('.')[0], { projection: { _id: 1 } });
if (!sound) {
res.writeHead(404);
res.write('Not found');
res.end();
return;
}
const file = await RocketChatFileCustomSoundsInstance.getFileWithReadStream(fileId);
if (!file) {
res.writeHead(404);
res.write('Not found');
res.end();
return;
}
res.setHeader('Content-Disposition', 'inline');
let fileUploadDate = undefined;
if (file.uploadDate != null) {
fileUploadDate = file.uploadDate.toUTCString();
}
const reqModifiedHeader = req.headers['if-modified-since'];
if (reqModifiedHeader != null) {
if (reqModifiedHeader === fileUploadDate) {
res.setHeader('Last-Modified', reqModifiedHeader);
res.writeHead(304);
res.end();
return;
}
}
res.setHeader('Cache-Control', 'public, max-age=0');
res.setHeader('Expires', '-1');
if (fileUploadDate != null) {
res.setHeader('Last-Modified', fileUploadDate);
} else {
res.setHeader('Last-Modified', new Date().toUTCString());
}
res.setHeader('Content-Type', file.contentType);
res.setHeader('Content-Length', file.length);
file.readStream.pipe(res);
});
});
settings.watchMultiple(['CustomSounds_Storage_Type', 'CustomSounds_FileSystemPath'], initializeCustomSoundsStorage);

View File

@@ -0,0 +1,141 @@
import { api } from '@rocket.chat/core-services';
import { EmojiCustom } from '@rocket.chat/models';
import limax from 'limax';
import { Meteor } from 'meteor/meteor';
import { trim } from '../../../../../lib/utils/stringUtils';
import { hasPermissionAsync } from '../../../authorization/hasPermission';
import { RocketChatFileEmojiCustomInstance } from '../startup/emoji-custom';
export type EmojiData = {
_id?: string;
name: string;
aliases?: string;
extension: string;
previousName?: string;
previousExtension?: string;
newFile?: boolean;
};
type EmojiDataWithParsedAliases = Omit<EmojiData, 'aliases' | '_id'> & { _id: string; aliases: string[] };
export async function insertOrUpdateEmoji(userId: string | null, emojiData: EmojiData): Promise<EmojiDataWithParsedAliases> {
if (!userId || !(await hasPermissionAsync(userId, 'manage-emoji'))) {
throw new Meteor.Error('not_authorized');
}
if (!trim(emojiData.name)) {
throw new Meteor.Error('error-the-field-is-required', 'The field Name is required', {
method: 'insertOrUpdateEmoji',
field: 'Name',
});
}
emojiData.name = limax(emojiData.name, { replacement: '_' });
// allow all characters except colon, whitespace, comma, >, <, &, ", ', /, \, (, )
// more practical than allowing specific sets of characters; also allows foreign languages
const nameValidation = /[\s,:><&"'\/\\\(\)]/;
const aliasValidation = /[:><&\|"'\/\\\(\)]/;
// silently strip colon; this allows for uploading :emojiname: as emojiname
emojiData.name = emojiData.name.replace(/:/g, '');
emojiData.aliases = emojiData.aliases?.replace(/:/g, '');
if (nameValidation.test(emojiData.name)) {
throw new Meteor.Error('error-input-is-not-a-valid-field', `${emojiData.name} is not a valid name`, {
method: 'insertOrUpdateEmoji',
input: emojiData.name,
field: 'Name',
});
}
let aliases: string[] = [];
if (emojiData.aliases) {
if (aliasValidation.test(emojiData.aliases)) {
throw new Meteor.Error('error-input-is-not-a-valid-field', `${emojiData.aliases} is not a valid alias set`, {
method: 'insertOrUpdateEmoji',
input: emojiData.aliases,
field: 'Alias_Set',
});
}
aliases = emojiData.aliases
.split(/\s*,\s*/)
.filter(Boolean)
.map((alias) => limax(alias, { replacement: '_' }))
.filter((alias) => alias !== emojiData.name);
}
emojiData.extension = emojiData.extension === 'svg+xml' ? 'png' : emojiData.extension;
const conflictingEmoji = await EmojiCustom.findOneByNamesOrAliases([emojiData.name, ...aliases], emojiData._id, {
projection: { _id: 1 },
});
if (conflictingEmoji) {
throw new Meteor.Error('Custom_Emoji_Error_Name_Or_Alias_Already_In_Use', 'The custom emoji or one of its aliases is already in use', {
method: 'insertOrUpdateEmoji',
});
}
if (typeof emojiData.extension === 'undefined') {
throw new Meteor.Error('error-the-field-is-required', 'The custom emoji file is required', {
method: 'insertOrUpdateEmoji',
});
}
if (!emojiData._id) {
// insert emoji
const createEmoji = {
name: emojiData.name,
aliases,
extension: emojiData.extension,
};
const _id = (await EmojiCustom.create(createEmoji)).insertedId;
void api.broadcast('emoji.updateCustom', createEmoji);
return { ...emojiData, ...createEmoji, _id };
}
// update emoji
if (emojiData.newFile) {
await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.name}.${emojiData.extension}`));
await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.name}.${emojiData.previousExtension}`));
await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.previousName}.${emojiData.extension}`));
await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.previousName}.${emojiData.previousExtension}`));
await EmojiCustom.setExtension(emojiData._id, emojiData.extension);
} else if (emojiData.name !== emojiData.previousName) {
const rs = await RocketChatFileEmojiCustomInstance.getFileWithReadStream(
encodeURIComponent(`${emojiData.previousName}.${emojiData.previousExtension}`),
);
if (rs) {
await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.name}.${emojiData.extension}`));
const ws = RocketChatFileEmojiCustomInstance.createWriteStream(
encodeURIComponent(`${emojiData.name}.${emojiData.previousExtension}`),
rs.contentType,
);
ws.on('end', () =>
RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.previousName}.${emojiData.previousExtension}`)),
);
rs.readStream.pipe(ws);
}
}
if (emojiData.name !== emojiData.previousName) {
await EmojiCustom.setName(emojiData._id, emojiData.name);
}
if (emojiData.aliases) {
await EmojiCustom.setAliases(emojiData._id, aliases);
} else {
await EmojiCustom.setAliases(emojiData._id, []);
}
void api.broadcast('emoji.updateCustom', { ...emojiData, aliases });
return { ...emojiData, aliases } as EmojiDataWithParsedAliases;
}

View File

@@ -0,0 +1,82 @@
import { api, Media } from '@rocket.chat/core-services';
import { EmojiCustom } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import limax from 'limax';
import { Meteor } from 'meteor/meteor';
import sharp from 'sharp';
import type { EmojiData } from './insertOrUpdateEmoji';
import { hasPermissionAsync } from '../../../authorization/hasPermission';
import { RocketChatFile } from '../../file';
import { RocketChatFileEmojiCustomInstance } from '../startup/emoji-custom';
const getFile = async (file: Buffer, extension: string) => {
if (extension !== 'svg+xml') {
return file;
}
return sharp(file).png().toBuffer();
};
export type EmojiDataWithAliases = Omit<EmojiData, 'aliases'> & { aliases?: string | string[] };
export async function uploadEmojiCustom(
userId: string | null,
binaryContent: string,
contentType: string,
emojiData: EmojiDataWithAliases,
) {
return uploadEmojiCustomWithBuffer(userId, Buffer.from(binaryContent, 'binary'), contentType, emojiData);
}
export async function uploadEmojiCustomWithBuffer(
userId: string | null,
buffer: Buffer,
contentType: string,
emojiData: EmojiDataWithAliases,
): Promise<void> {
// technically, since this method doesnt have any datatype validations, users can
// upload videos as emojis. The FE won't play them, but they will waste space for sure.
if (!userId || !(await hasPermissionAsync(userId, 'manage-emoji'))) {
throw new Meteor.Error('not_authorized');
}
if (!Array.isArray(emojiData.aliases)) {
// delete aliases for notification purposes. here, it is a string or undefined rather than an array
delete emojiData.aliases;
}
emojiData.name = limax(emojiData.name, { replacement: '_' });
const file = await getFile(buffer, emojiData.extension);
emojiData.extension = emojiData.extension === 'svg+xml' ? 'png' : emojiData.extension;
let fileBuffer;
// sharp doesn't support these formats without imagemagick or libvips installed
// so they will be stored as they are :(
if (['gif', 'x-icon', 'bmp', 'webm'].includes(emojiData.extension)) {
fileBuffer = file;
} else {
// This is to support the idea of having "sticker-like" emojis
const { data: resizedEmojiBuffer } = await Media.resizeFromBuffer(file, 512, 512, true, false, false, 'inside');
fileBuffer = resizedEmojiBuffer;
}
const rs = RocketChatFile.bufferToStream(fileBuffer);
await RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${emojiData.name}.${emojiData.extension}`));
return new Promise((resolve) => {
const ws = RocketChatFileEmojiCustomInstance.createWriteStream(
encodeURIComponent(`${emojiData.name}.${emojiData.extension}`),
contentType,
);
ws.on('end', async () => {
const etag = Random.hexString(6);
await EmojiCustom.setETagByName(emojiData.name, etag);
setTimeout(() => api.broadcast('emoji.updateCustom', { ...emojiData, etag }), 500);
resolve();
});
rs.pipe(ws);
});
}

View File

@@ -0,0 +1,125 @@
import { EmojiCustom } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { WebApp } from 'meteor/webapp';
import _ from 'underscore';
import { settings } from '../../../../settings';
import { SystemLogger } from '../../../logger/system';
import { RocketChatFile } from '../../file';
export let RocketChatFileEmojiCustomInstance;
const writeSvgFallback = (res, req) => {
res.setHeader('Content-Type', 'image/svg+xml');
res.setHeader('Cache-Control', 'public, max-age=0');
res.setHeader('Expires', '-1');
res.setHeader('Last-Modified', 'Thu, 01 Jan 2015 00:00:00 GMT');
const reqModifiedHeader = req.headers['if-modified-since'];
if (reqModifiedHeader != null) {
if (reqModifiedHeader === 'Thu, 01 Jan 2015 00:00:00 GMT') {
res.writeHead(304);
res.end();
return;
}
}
const color = '#000';
const initials = '?';
const svg = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" pointer-events="none" width="50" height="50" style="width: 50px; height: 50px; background-color: ${color};">
<text text-anchor="middle" y="50%" x="50%" dy="0.36em" pointer-events="auto" fill="#ffffff" font-family="Helvetica, Arial, Lucida Grande, sans-serif" style="font-weight: 400; font-size: 28px;">
${initials}
</text>
</svg>`;
res.write(svg);
res.end();
};
const initializeEmojiCustomStorage = () => {
let storeType = 'GridFS';
if (settings.get('EmojiUpload_Storage_Type')) {
storeType = settings.get('EmojiUpload_Storage_Type');
}
const RocketChatStore = RocketChatFile[storeType];
if (RocketChatStore == null) {
throw new Error(`Invalid RocketChatStore type [${storeType}]`);
}
SystemLogger.info({
msg: 'Using custom emoji storage',
storeType,
});
let path = '~/uploads';
if (settings.get('EmojiUpload_FileSystemPath') != null) {
if (settings.get('EmojiUpload_FileSystemPath').trim() !== '') {
path = settings.get('EmojiUpload_FileSystemPath');
}
}
RocketChatFileEmojiCustomInstance = new RocketChatStore({
name: 'custom_emoji',
absolutePath: path,
});
};
Meteor.startup(() => {
initializeEmojiCustomStorage();
return WebApp.connectHandlers.use('/emoji-custom/', async (req, res /* , next*/) => {
const params = { emoji: decodeURIComponent(req.url.replace(/^\//, '').replace(/\?.*$/, '')) };
if (_.isEmpty(params.emoji)) {
res.writeHead(403);
res.write('Forbidden');
res.end();
return;
}
res.setHeader('Content-Disposition', 'inline');
const emoji = await EmojiCustom.findOneByName(params.emoji.split('.')[0], { projection: { _id: 1 } });
if (!emoji) {
return writeSvgFallback(res, req);
}
const file = await RocketChatFileEmojiCustomInstance.getFileWithReadStream(encodeURIComponent(params.emoji));
if (!file) {
// use code from username initials renderer until file upload is complete
return writeSvgFallback(res, req);
}
const fileUploadDate = file.uploadDate != null ? file.uploadDate.toUTCString() : undefined;
const reqModifiedHeader = req.headers['if-modified-since'];
if (reqModifiedHeader != null && reqModifiedHeader === fileUploadDate) {
res.setHeader('Last-Modified', reqModifiedHeader);
res.writeHead(304);
res.end();
return;
}
res.setHeader('Cache-Control', 'public, max-age=31536000');
res.setHeader('Last-Modified', fileUploadDate || new Date().toUTCString());
res.setHeader('Content-Length', file.length);
if (/^svg$/i.test(params.emoji.split('.').pop())) {
res.setHeader('Content-Type', 'image/svg+xml');
} else if (/^png$/i.test(params.emoji.split('.').pop())) {
res.setHeader('Content-Type', 'image/png');
} else {
res.setHeader('Content-Type', 'image/jpeg');
}
file.readStream.pipe(res);
});
});
settings.watchMultiple(['EmojiUpload_Storage_Type', 'EmojiUpload_FileSystemPath'], initializeEmojiCustomStorage);

View File

@@ -0,0 +1,13 @@
import { Meteor } from 'meteor/meteor';
import { shortnameToUnicode } from '../../../../app/emoji-native/lib/shortnameToUnicode';
import { callbacks } from '../../callbacks';
Meteor.startup(() => {
callbacks.add(
'beforeSendMessageNotifications',
(message) => shortnameToUnicode(message),
callbacks.priority.MEDIUM,
'native-emoji-shortnameToUnicode',
);
});

View File

@@ -0,0 +1,42 @@
import { getEmojiConfig } from '../../../../app/emoji-native/lib/getEmojiConfig';
import { legacyEmojioneMap } from '../../../../app/emoji-native/lib/legacyEmojioneMap';
import { emoji } from '../../messaging/emoji';
const config = getEmojiConfig(emoji);
emoji.packages.native = {
emojiCategories: config.emojiCategories as typeof emoji.packages.native.emojiCategories,
emojisByCategory: config.emojisByCategory,
toneList: config.toneList,
render: config.render,
renderPicker: config.renderPicker,
sprites: config.sprites,
};
for (const [key, currentEmoji] of Object.entries(config.emojiList)) {
currentEmoji.emojiPackage = 'native';
emoji.list[key] = currentEmoji;
if (currentEmoji.shortnames) {
currentEmoji.shortnames.forEach((shortname: string) => {
emoji.list[shortname] = currentEmoji;
});
}
}
// Register legacy emojione shortcodes so old reactions and stored shortcodes resolve correctly
for (const [shortcode, unicode] of Object.entries(legacyEmojioneMap)) {
const key = `:${shortcode}:`;
if (emoji.list[key]) continue; // already registered by emojibase
emoji.list[key] = {
uc_base: '',
uc_output: '',
uc_match: '',
uc_greedy: '',
shortnames: [],
category: '',
emojiPackage: 'native',
unicode,
};
}

View File

@@ -0,0 +1,129 @@
import http from 'node:http';
import https from 'node:https';
import _ from 'underscore';
import { forceDownload } from './helper';
import { settings } from '../../../../settings';
import { SystemLogger } from '../../../logger/system';
import { FileUploadClass, FileUpload } from '../lib/FileUpload';
import type { S3Options } from '../ufs/AmazonS3/server';
import '../ufs/AmazonS3/server';
const hasScheme = (value: string) => /^[a-z][a-z0-9+.-]*:\/\//i.test(value);
const get: FileUploadClass['get'] = async function (this: FileUploadClass, file, req, res) {
const forcedDownload = forceDownload(req);
const fileUrl = await this.store.getRedirectURL(file, forcedDownload);
if (!fileUrl || !file.store) {
res.end();
return;
}
const storeType = file.store.split(':').pop();
if (settings.get(`FileUpload_S3_Proxy_${storeType}`)) {
const request = /^https:/.test(fileUrl) ? https : http;
FileUpload.proxyFile(file.name || '', fileUrl, forcedDownload, request, req, res);
return;
}
FileUpload.redirectToFile(fileUrl, req, res);
};
const copy: FileUploadClass['copy'] = async function (this: FileUploadClass, file, out) {
const fileUrl = await this.store.getRedirectURL(file);
if (!fileUrl) {
out.end();
return;
}
const request = /^https:/.test(fileUrl) ? https : http;
return new Promise((resolve) => {
request.get(fileUrl, (fileRes) => fileRes.pipe(out).on('finish', () => resolve()));
});
};
const AmazonS3Uploads = new FileUploadClass({
name: 'AmazonS3:Uploads',
get,
copy,
// store setted bellow
});
const AmazonS3Avatars = new FileUploadClass({
name: 'AmazonS3:Avatars',
get,
copy,
// store setted bellow
});
const AmazonS3UserDataFiles = new FileUploadClass({
name: 'AmazonS3:UserDataFiles',
get,
copy,
// store setted bellow
});
const configure = _.debounce(() => {
const Bucket = settings.get<string>('FileUpload_S3_Bucket');
const Acl = settings.get<string>('FileUpload_S3_Acl');
const AWSAccessKeyId = settings.get<string>('FileUpload_S3_AWSAccessKeyId');
const AWSSecretAccessKey = settings.get<string>('FileUpload_S3_AWSSecretAccessKey');
const URLExpiryTimeSpan = settings.get<number>('FileUpload_S3_URLExpiryTimeSpan');
const Region = settings.get<string>('FileUpload_S3_Region');
const ForcePathStyle = settings.get<boolean>('FileUpload_S3_ForcePathStyle');
// const CDN = RocketChat.settings.get('FileUpload_S3_CDN');
const BucketURL = settings.get<string>('FileUpload_S3_BucketURL');
if (!Bucket) {
return;
}
const config: Omit<S3Options, 'name' | 'getPath'> = {
connection: {
forcePathStyle: ForcePathStyle,
followRegionRedirects: true,
},
params: {
Bucket,
ACL: Acl,
},
URLExpiryTimeSpan,
};
if (Region) {
config.connection.region = Region;
}
// Back-compat: AWS SDK v2 defaulted unset region to us-east-1; v3 throws.
if (!Region && !process.env.AWS_REGION) {
config.connection.region = 'us-east-1';
SystemLogger.warn(
'FileUpload_S3_Region is empty and AWS_REGION is not set; defaulting to us-east-1. Set FileUpload_S3_Region or AWS_REGION to silence this warning.',
);
}
if (AWSAccessKeyId && AWSSecretAccessKey) {
config.connection.credentials = {
accessKeyId: AWSAccessKeyId,
secretAccessKey: AWSSecretAccessKey,
};
}
// Back-compat: AWS SDK v2 accepted scheme-less endpoints; v3 throws.
if (BucketURL) {
const isValidScheme = hasScheme(BucketURL);
config.connection.endpoint = isValidScheme ? BucketURL : `https://${BucketURL}`;
if (!isValidScheme) {
SystemLogger.warn(`FileUpload_S3_BucketURL "${BucketURL}" has no scheme; defaulting to "https://${BucketURL}".`);
}
}
AmazonS3Uploads.store = FileUpload.configureUploadsStore('AmazonS3', AmazonS3Uploads.name, config);
AmazonS3Avatars.store = FileUpload.configureUploadsStore('AmazonS3', AmazonS3Avatars.name, config);
AmazonS3UserDataFiles.store = FileUpload.configureUploadsStore('AmazonS3', AmazonS3UserDataFiles.name, config);
}, 500);
settings.watchByRegex(/^FileUpload_S3_/, configure);

View File

@@ -0,0 +1,144 @@
import fsp from 'node:fs/promises';
import { getContentDisposition } from './helper';
import { settings } from '../../../../settings';
import { UploadFS } from '../../../../ufs';
import { FileUploadClass, FileUpload } from '../lib/FileUpload';
import { getFileRange, setRangeHeaders } from '../lib/ranges';
const FileSystemUploads = new FileUploadClass({
name: 'FileSystem:Uploads',
// store setted bellow
async get(file, req, res) {
if (!this.store || !file) {
return;
}
const filePath = await this.store.getFilePath(file._id, file);
const options: { start?: number; end?: number } = {};
try {
const stat = await fsp.stat(filePath);
if (!stat?.isFile()) {
res.writeHead(404);
res.end();
return;
}
file = FileUpload.addExtensionTo(file);
res.setHeader('Content-Disposition', `${getContentDisposition(req)}; filename*=UTF-8''${encodeURIComponent(file.name || '')}`);
file.uploadedAt && res.setHeader('Last-Modified', file.uploadedAt.toUTCString());
res.setHeader('Content-Type', file.type || 'application/octet-stream');
res.setHeader('Accept-Ranges', 'bytes');
if (req.headers.range) {
const range = getFileRange(file, req);
if (range) {
setRangeHeaders(range, file, res);
if (range.outOfRange) {
return;
}
options.start = range.start;
options.end = range.stop;
}
}
// set content-length if range has not set
if (!res.getHeader('Content-Length')) {
res.setHeader('Content-Length', file.size || 0);
}
(await this.store.getReadStream(file._id, file, options)).pipe(res);
} catch (e) {
res.writeHead(404);
res.end();
}
},
async copy(file, out) {
if (!this.store) {
return;
}
const filePath = await this.store.getFilePath(file._id, file);
try {
const stat = await fsp.stat(filePath);
if (stat?.isFile()) {
file = FileUpload.addExtensionTo(file);
(await this.store.getReadStream(file._id, file)).pipe(out);
}
} catch (e) {
out.end();
}
},
});
const FileSystemAvatars = new FileUploadClass({
name: 'FileSystem:Avatars',
// store setted bellow
async get(file, _req, res) {
if (!this.store) {
return;
}
const filePath = await this.store.getFilePath(file._id, file);
try {
const stat = await fsp.stat(filePath);
if (stat?.isFile()) {
file = FileUpload.addExtensionTo(file);
(await this.store.getReadStream(file._id, file)).pipe(res);
}
} catch (e) {
res.writeHead(404);
res.end();
}
},
});
const FileSystemUserDataFiles = new FileUploadClass({
name: 'FileSystem:UserDataFiles',
async get(file, _req, res) {
if (!this.store) {
return;
}
const filePath = await this.store.getFilePath(file._id, file);
try {
const stat = await fsp.stat(filePath);
if (stat?.isFile()) {
file = FileUpload.addExtensionTo(file);
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(file.name || '')}`);
file.uploadedAt && res.setHeader('Last-Modified', file.uploadedAt.toUTCString());
res.setHeader('Content-Type', file.type || '');
res.setHeader('Content-Length', file.size || 0);
(await this.store.getReadStream(file._id, file)).pipe(res);
}
} catch (e) {
res.writeHead(404);
res.end();
}
},
});
settings.watch('FileUpload_FileSystemPath', () => {
const options = {
path: settings.get('FileUpload_FileSystemPath'), // '/tmp/uploads/photos',
};
FileSystemUploads.store = FileUpload.configureUploadsStore('Local', FileSystemUploads.name, options);
FileSystemAvatars.store = FileUpload.configureUploadsStore('Local', FileSystemAvatars.name, options);
FileSystemUserDataFiles.store = FileUpload.configureUploadsStore('Local', FileSystemUserDataFiles.name, options);
// DEPRECATED backwards compatibility (remove)
UploadFS.getStores().fileSystem = UploadFS.getStores()[FileSystemUploads.name];
});

View File

@@ -0,0 +1,94 @@
import http from 'node:http';
import https from 'node:https';
import _ from 'underscore';
import { forceDownload } from './helper';
import { settings } from '../../../../settings';
import { FileUploadClass, FileUpload } from '../lib/FileUpload';
import '../ufs/GoogleStorage/server';
const get: FileUploadClass['get'] = async function (this: FileUploadClass, file, req, res) {
const forcedDownload = forceDownload(req);
const fileUrl = await this.store.getRedirectURL(file, forcedDownload);
if (!fileUrl || !file.store) {
res.end();
return;
}
const storeType = file.store.split(':').pop();
if (settings.get(`FileUpload_GoogleStorage_Proxy_${storeType}`)) {
const request = /^https:/.test(fileUrl) ? https : http;
FileUpload.proxyFile(file.name || '', fileUrl, forcedDownload, request, req, res);
return;
}
FileUpload.redirectToFile(fileUrl, req, res);
};
const copy: FileUploadClass['copy'] = async function (this: FileUploadClass, file, out) {
const fileUrl = await this.store.getRedirectURL(file, false);
if (!fileUrl) {
out.end();
return;
}
const request = /^https:/.test(fileUrl) ? https : http;
return new Promise((resolve) => {
request.get(fileUrl, (fileRes) => fileRes.pipe(out).on('finish', () => resolve()));
});
};
const GoogleCloudStorageUploads = new FileUploadClass({
name: 'GoogleCloudStorage:Uploads',
get,
copy,
// store setted bellow
});
const GoogleCloudStorageAvatars = new FileUploadClass({
name: 'GoogleCloudStorage:Avatars',
get,
copy,
// store setted bellow
});
const GoogleCloudStorageUserDataFiles = new FileUploadClass({
name: 'GoogleCloudStorage:UserDataFiles',
get,
copy,
// store setted bellow
});
const configure = _.debounce(() => {
const bucket = settings.get('FileUpload_GoogleStorage_Bucket');
const projectId = settings.get('FileUpload_GoogleStorage_ProjectId');
const accessId = settings.get('FileUpload_GoogleStorage_AccessId');
const secret = settings.get('FileUpload_GoogleStorage_Secret');
const URLExpiryTimeSpan = settings.get<number>('FileUpload_GoogleStorage_URLExpiryTimeSpan');
if (!bucket || !accessId || !secret) {
return;
}
const config = {
connection: {
credentials: {
client_email: accessId,
private_key: secret,
},
projectId,
},
bucket,
URLExpiryTimeSpan,
};
GoogleCloudStorageUploads.store = FileUpload.configureUploadsStore('GoogleStorage', GoogleCloudStorageUploads.name, config);
GoogleCloudStorageAvatars.store = FileUpload.configureUploadsStore('GoogleStorage', GoogleCloudStorageAvatars.name, config);
GoogleCloudStorageUserDataFiles.store = FileUpload.configureUploadsStore('GoogleStorage', GoogleCloudStorageUserDataFiles.name, config);
}, 500);
settings.watchByRegex(/^FileUpload_GoogleStorage_/, configure);

View File

@@ -0,0 +1,211 @@
import type * as http from 'node:http';
import type { TransformCallback, TransformOptions } from 'node:stream';
import stream from 'node:stream';
import zlib from 'node:zlib';
import type { IUpload } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { getContentDisposition } from './helper';
import { UploadFS } from '../../../../ufs';
import { FileUploadClass, FileUpload } from '../lib/FileUpload';
import { getFileRange, setRangeHeaders } from '../lib/ranges';
const logger = new Logger('FileUpload');
class ExtractRange extends stream.Transform {
private start: number;
private stop: number;
private bytes_read: number;
constructor(options: TransformOptions & { start: number; stop: number }) {
super(options);
this.start = options.start;
this.stop = options.stop;
this.bytes_read = 0;
}
override _transform(chunk: any, _enc: BufferEncoding, cb: TransformCallback) {
if (this.bytes_read > this.stop) {
// done reading
this.end();
} else if (this.bytes_read + chunk.length < this.start) {
// this chunk is still before the start byte
} else {
let start;
let stop;
if (this.start <= this.bytes_read) {
start = 0;
} else {
start = this.start - this.bytes_read;
}
if (this.stop - this.bytes_read + 1 < chunk.length) {
stop = this.stop - this.bytes_read + 1;
} else {
stop = chunk.length;
}
const newchunk = chunk.slice(start, stop);
this.push(newchunk);
}
this.bytes_read += chunk.length;
cb();
}
}
// code from: https://github.com/jalik/jalik-ufs/blob/master/ufs-server.js#L310
const readFromGridFS = async function (
storeName: string | undefined,
fileId: string,
file: IUpload,
req: http.IncomingMessage,
res: http.ServerResponse,
) {
if (!storeName) {
return;
}
const store = UploadFS.getStore(storeName);
const rs = await store.getReadStream(fileId, file);
const ws = new stream.PassThrough();
res.setHeader('Accept-Ranges', 'bytes');
[rs, ws].forEach((stream) =>
stream.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);
const range = getFileRange(file, req);
if (range) {
setRangeHeaders(range, file, res);
if (range.outOfRange) {
return;
}
logger.debug('File upload extracting range');
ws.pipe(new ExtractRange({ start: range.start, stop: range.stop })).pipe(res);
return;
}
const accept = (Array.isArray(req.headers['accept-encoding']) ? req.headers['accept-encoding'][0] : req.headers['accept-encoding']) || '';
// Compress data using gzip
if (accept.match(/\bgzip\b/)) {
res.setHeader('Content-Encoding', 'gzip');
res.removeHeader('Content-Length');
res.writeHead(200);
ws.pipe(zlib.createGzip()).pipe(res);
return;
}
// Compress data using deflate
if (accept.match(/\bdeflate\b/)) {
res.setHeader('Content-Encoding', 'deflate');
res.removeHeader('Content-Length');
res.writeHead(200);
ws.pipe(zlib.createDeflate()).pipe(res);
return;
}
res.writeHead(200);
ws.pipe(res);
};
const copyFromGridFS = async function (storeName: string | undefined, fileId: string, file: IUpload, out: stream.Writable) {
if (!storeName) {
return;
}
const store = UploadFS.getStore(storeName);
const rs = await store.getReadStream(fileId, file);
[rs, out].forEach((stream) =>
stream.on('error', (err) => {
store.onReadError.call(store, err, fileId, file);
out.end();
}),
);
rs.pipe(out);
};
FileUpload.configureUploadsStore('GridFS', 'GridFS:Uploads', {
collectionName: 'rocketchat_uploads',
});
FileUpload.configureUploadsStore('GridFS', 'GridFS:UserDataFiles', {
collectionName: 'rocketchat_userDataFiles',
});
// DEPRECATED: backwards compatibility (remove)
UploadFS.getStores().rocketchat_uploads = UploadFS.getStores()['GridFS:Uploads'];
FileUpload.configureUploadsStore('GridFS', 'GridFS:Avatars', {
collectionName: 'rocketchat_avatars',
});
new FileUploadClass({
name: 'GridFS:Uploads',
async get(file, req, res) {
file = FileUpload.addExtensionTo(file);
res.setHeader('Content-Disposition', `${getContentDisposition(req)}; filename*=UTF-8''${encodeURIComponent(file.name || '')}`);
file.uploadedAt && res.setHeader('Last-Modified', file.uploadedAt.toUTCString());
res.setHeader('Content-Type', file.type || 'application/octet-stream');
res.setHeader('Content-Length', file.size || 0);
await readFromGridFS(file.store, file._id, file, req, res);
},
async copy(file, out) {
await copyFromGridFS(file.store, file._id, file, out);
},
});
new FileUploadClass({
name: 'GridFS:UserDataFiles',
async get(file, req, res) {
file = FileUpload.addExtensionTo(file);
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(file.name || '')}`);
file.uploadedAt && res.setHeader('Last-Modified', file.uploadedAt.toUTCString());
res.setHeader('Content-Type', file.type || '');
res.setHeader('Content-Length', file.size || 0);
await readFromGridFS(file.store, file._id, file, req, res);
},
async copy(file, out) {
await copyFromGridFS(file.store, file._id, file, out);
},
});
new FileUploadClass({
name: 'GridFS:Avatars',
async get(file, req, res) {
file = FileUpload.addExtensionTo(file);
await readFromGridFS(file.store, file._id, file, req, res);
},
async copy(file, out) {
await copyFromGridFS(file.store, file._id, file, out);
},
});

View File

@@ -0,0 +1,74 @@
import _ from 'underscore';
import { settings } from '../../../../settings';
import { SystemLogger } from '../../../logger/system';
import { FileUploadClass, FileUpload } from '../lib/FileUpload';
import '../ufs/Webdav/server';
const get: FileUploadClass['get'] = async function (this: FileUploadClass, file, _req, res) {
(await this.store.getReadStream(file._id, file))
.on('error', () => {
SystemLogger.error('An error ocurred when fetching the file');
res.writeHead(503);
res.end();
})
.on('data', (chunk) => {
res.write(chunk);
})
.on('end', res.end.bind(res));
};
const copy: FileUploadClass['copy'] = async function (this: FileUploadClass, file, out) {
return new Promise(async (resolve) => {
(await this.store.getReadStream(file._id, file)).pipe(out).on('finish', () => resolve());
});
};
const WebdavUploads = new FileUploadClass({
name: 'Webdav:Uploads',
get,
copy,
// store setted bellow
});
const WebdavAvatars = new FileUploadClass({
name: 'Webdav:Avatars',
get,
copy,
// store setted bellow
});
const WebdavUserDataFiles = new FileUploadClass({
name: 'Webdav:UserDataFiles',
get,
copy,
// store setted bellow
});
const configure = _.debounce(() => {
const uploadFolderPath = settings.get('FileUpload_Webdav_Upload_Folder_Path');
const server = settings.get('FileUpload_Webdav_Server_URL');
const username = settings.get('FileUpload_Webdav_Username');
const password = settings.get('FileUpload_Webdav_Password');
if (!server || !username || !password) {
return;
}
const config = {
connection: {
credentials: {
server,
username,
password,
},
},
uploadFolderPath,
};
WebdavUploads.store = FileUpload.configureUploadsStore('Webdav', WebdavUploads.name, config);
WebdavAvatars.store = FileUpload.configureUploadsStore('Webdav', WebdavAvatars.name, config);
WebdavUserDataFiles.store = FileUpload.configureUploadsStore('Webdav', WebdavUserDataFiles.name, config);
}, 500);
settings.watchByRegex(/^FileUpload_Webdav_/, configure);

View File

@@ -0,0 +1,26 @@
import _ from 'underscore';
import { settings } from '../../../../settings';
import { UploadFS } from '../../../../ufs';
import { SystemLogger } from '../../../logger/system';
import './AmazonS3';
import './FileSystem';
import './GoogleStorage';
import './GridFS';
import './Webdav';
const configStore = _.debounce(() => {
const store = settings.get('FileUpload_Storage_Type');
if (store) {
SystemLogger.info({
msg: 'Setting default file store',
store,
});
UploadFS.getStores().Avatars = UploadFS.getStore(`${store}:Avatars`);
UploadFS.getStores().Uploads = UploadFS.getStore(`${store}:Uploads`);
UploadFS.getStores().UserDataFiles = UploadFS.getStore(`${store}:UserDataFiles`);
}
}, 1000);
settings.watchByRegex(/^FileUpload_/, configStore);

View File

@@ -0,0 +1,21 @@
import type http from 'node:http';
import URL from 'node:url';
export const forceDownload = (req: http.IncomingMessage): boolean => {
const { query } = URL.parse(req.url || '', true);
const forceDownload = typeof query.download !== 'undefined';
if (forceDownload) {
return true;
}
return query.contentDisposition === 'attachment';
};
export const getContentDisposition = (req: http.IncomingMessage): string => {
const { query } = URL.parse(req.url || '', true);
if (query.contentDisposition === 'inline') {
return 'inline';
}
return 'attachment';
};

View File

@@ -0,0 +1,6 @@
import './lib/FileUploadBase';
import { FileUpload } from './lib/FileUpload';
import './lib/requests';
import './config/_configUploadStorage';
export { FileUpload };

View File

@@ -0,0 +1,14 @@
export function isImagePreviewSupported(mimeType: string): boolean {
// Only attempt preview generation for image types that can be processed by Sharp
// This excludes vendor-specific formats like image/vnd.dwg that cannot be rendered
return (
mimeType === 'image/bmp' ||
mimeType === 'image/x-windows-bmp' ||
mimeType === 'image/jpeg' ||
mimeType === 'image/pjpeg' ||
mimeType === 'image/png' ||
mimeType === 'image/gif' ||
mimeType === 'image/webp' ||
mimeType === 'image/svg+xml'
);
}

View File

@@ -0,0 +1,471 @@
import { expect } from 'chai';
import { before, beforeEach, describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
import { createFakeMessageWithAttachment } from '../../../../../tests/mocks/data';
const fakeStorageModel = { findOneById: sinon.stub(), deleteFile: sinon.stub() };
const settingsStub = { watch: sinon.stub(), get: sinon.stub() };
const settingsGetMap = new Map();
const messagesModelStub = {
find: sinon.stub(),
};
const usersModelStub = {
findOneByIdAndLoginToken: sinon.stub(),
};
const subscriptionsModelStub = {
findOneByRoomIdAndUserId: sinon.stub(),
};
const validateAndDecodeJWTStub = sinon.stub();
const systemLoggerStub = {
error: sinon.stub(),
};
const roomCoordinatorStub = {
getRoomDirectives: sinon.stub(),
};
const { FileUpload, FileUploadClass } = proxyquire.noCallThru().load('./FileUpload', {
'@rocket.chat/models': {
Messages: messagesModelStub,
Users: usersModelStub,
Subscriptions: subscriptionsModelStub,
},
'meteor/check': sinon.stub(),
'meteor/meteor': sinon.stub(),
'meteor/ostrio:cookies': { Cookies: sinon.stub() },
'sharp': sinon.stub(),
'stream-buffers': sinon.stub(),
'@rocket.chat/tools': sinon.stub(),
'../../../i18n': sinon.stub(),
'../../../logger/system': { SystemLogger: systemLoggerStub },
'../../../rooms/roomCoordinator': { roomCoordinator: roomCoordinatorStub },
'../../../../ufs': sinon.stub(),
'../../../../ufs/ufs-methods': sinon.stub(),
'../../../../settings': { settings: settingsStub },
'../../../../../app/utils/lib/mimeTypes': sinon.stub(),
'../../../utils/lib/JWTHelper': {
validateAndDecodeJWT: validateAndDecodeJWTStub,
generateJWT: sinon.stub(),
},
'../../../utils/restrictions': sinon.stub(),
'../../../../api/lib/MultipartUploadHandler': sinon.stub(),
'@rocket.chat/account-utils': { hashLoginToken: sinon.stub().callsFake((token) => `hashed_${token}`) },
});
describe('FileUpload', () => {
before(() => {
new FileUploadClass({ name: 'fakeStorage:Uploads', model: fakeStorageModel, store: {} });
settingsGetMap.set('FileUpload_Storage_Type', 'fakeStorage');
settingsStub.get.callsFake((settingName) => settingsGetMap.get(settingName));
});
beforeEach(() => {
messagesModelStub.find.reset();
fakeStorageModel.findOneById.reset();
fakeStorageModel.deleteFile.reset();
usersModelStub.findOneByIdAndLoginToken.reset();
subscriptionsModelStub.findOneByRoomIdAndUserId.reset();
validateAndDecodeJWTStub.reset();
systemLoggerStub.error.reset();
roomCoordinatorStub.getRoomDirectives.reset();
settingsGetMap.clear();
settingsGetMap.set('FileUpload_Storage_Type', 'fakeStorage');
});
it('should not remove any file if no room id is provided', async () => {
expect(await FileUpload.removeFilesByRoomId()).to.be.undefined;
expect(messagesModelStub.find.called).to.be.false;
expect(fakeStorageModel.findOneById.called).to.be.false;
});
it('should not remove any file if an empty room id is provided', async () => {
expect(await FileUpload.removeFilesByRoomId('')).to.be.undefined;
expect(messagesModelStub.find.called).to.be.false;
expect(fakeStorageModel.findOneById.called).to.be.false;
});
it('should not remove any file if an invalid room id is provided', async () => {
messagesModelStub.find.returns([]);
expect(await FileUpload.removeFilesByRoomId('invalid')).to.be.undefined;
expect(messagesModelStub.find.called).to.be.true;
expect(fakeStorageModel.findOneById.called).to.be.false;
});
it('should delete file from storage if message contains a single file', async () => {
fakeStorageModel.findOneById.resolves({ _id: 'file-id', store: 'fakeStorage:Uploads' });
const fakeMessage = createFakeMessageWithAttachment();
messagesModelStub.find.returns([fakeMessage]);
expect(await FileUpload.removeFilesByRoomId('invalid')).to.be.undefined;
expect(messagesModelStub.find.called).to.be.true;
expect(fakeStorageModel.findOneById.calledOnceWith(fakeMessage.files?.[0]._id)).to.be.true;
expect(fakeStorageModel.deleteFile.calledOnceWith('file-id')).to.be.true;
});
it('should delete multiple files from storage if message contains many files (e.g. image and thumbnail)', async () => {
fakeStorageModel.findOneById.callsFake((_id) => ({ _id, store: 'fakeStorage:Uploads' }));
const fakeMessage = createFakeMessageWithAttachment({
files: [
{ _id: 'file-id', name: 'image', size: 100, type: 'image/png', format: 'png' },
{ _id: 'thumbnail-id', name: 'thumbnail-image', size: 25, type: 'image/png', format: 'png' },
],
});
messagesModelStub.find.returns([fakeMessage]);
expect(await FileUpload.removeFilesByRoomId('invalid')).to.be.undefined;
expect(messagesModelStub.find.called).to.be.true;
expect(fakeStorageModel.findOneById.calledTwice).to.be.true;
expect(fakeStorageModel.findOneById.calledWith('file-id')).to.be.true;
expect(fakeStorageModel.findOneById.calledWith('thumbnail-id')).to.be.true;
expect(fakeStorageModel.deleteFile.calledTwice).to.be.true;
expect(fakeStorageModel.deleteFile.calledWith('file-id')).to.be.true;
expect(fakeStorageModel.deleteFile.calledWith('thumbnail-id')).to.be.true;
});
describe('requestCanAccessFiles', () => {
it('should allow access if FileUpload_ProtectFiles is false', async () => {
settingsGetMap.set('FileUpload_ProtectFiles', false);
const request = {
headers: {},
url: '/file-upload/test-file-id/test-file.png',
} as any;
const result = await FileUpload.requestCanAccessFiles(request);
expect(result).to.be.true;
});
it('should allow access if no url is provided', async () => {
settingsGetMap.set('FileUpload_ProtectFiles', true);
const request = {
headers: {},
url: undefined,
} as any;
const result = await FileUpload.requestCanAccessFiles(request);
expect(result).to.be.true;
});
it('should deny access if FileUpload_Enable_json_web_token_for_files is true but no token is provided', async () => {
settingsGetMap.set('FileUpload_ProtectFiles', true);
settingsGetMap.set('FileUpload_Enable_json_web_token_for_files', true);
const request = {
headers: {},
url: '/file-upload/test-file-id/test-file.png',
} as any;
const file = {
_id: 'test-file-id',
rid: 'test-room-id',
} as any;
const result = await FileUpload.requestCanAccessFiles(request, file);
expect(result).to.be.false;
});
it('should deny access if FileUpload_json_web_token_secret_for_files is not configured', async () => {
settingsGetMap.set('FileUpload_ProtectFiles', true);
settingsGetMap.set('FileUpload_Enable_json_web_token_for_files', true);
settingsGetMap.set('FileUpload_json_web_token_secret_for_files', '');
const request = {
headers: {},
url: '/file-upload/test-file-id/test-file.png?token=some-token',
} as any;
const file = {
_id: 'test-file-id',
rid: 'test-room-id',
} as any;
const result = await FileUpload.requestCanAccessFiles(request, file);
expect(result).to.be.false;
expect(systemLoggerStub.error.calledOnce).to.be.true;
});
it('should deny access if an invalid token is provided', async () => {
settingsGetMap.set('FileUpload_ProtectFiles', true);
settingsGetMap.set('FileUpload_Enable_json_web_token_for_files', true);
settingsGetMap.set('FileUpload_json_web_token_secret_for_files', 'test-secret');
validateAndDecodeJWTStub.returns(null);
const request = {
headers: {},
url: '/file-upload/test-file-id/test-file.png?token=invalid-token',
} as any;
const file = {
_id: 'test-file-id',
rid: 'test-room-id',
} as any;
const result = await FileUpload.requestCanAccessFiles(request, file);
expect(result).to.be.false;
expect(validateAndDecodeJWTStub.calledOnce).to.be.true;
});
it('should deny access if token is invalid or payload cannot be decoded', async () => {
settingsGetMap.set('FileUpload_ProtectFiles', true);
settingsGetMap.set('FileUpload_Enable_json_web_token_for_files', true);
settingsGetMap.set('FileUpload_json_web_token_secret_for_files', 'test-secret');
validateAndDecodeJWTStub.returns(null);
const request = {
headers: {},
url: '/file-upload/test-file-id/test-file.png?token=valid-token',
} as any;
const file = {
_id: 'test-file-id',
rid: 'test-room-id',
} as any;
const result = await FileUpload.requestCanAccessFiles(request, file);
expect(result).to.be.false;
});
it('should deny access if the fileId and rid in the token do not match the requested file', async () => {
settingsGetMap.set('FileUpload_ProtectFiles', true);
settingsGetMap.set('FileUpload_Enable_json_web_token_for_files', true);
settingsGetMap.set('FileUpload_json_web_token_secret_for_files', 'test-secret');
validateAndDecodeJWTStub.returns({ fileId: 'different-file-id', rid: 'different-room-id', userId: 'test-user-id' });
const request = {
headers: {},
url: '/file-upload/test-file-id/test-file.png?token=valid-token',
} as any;
const file = {
_id: 'test-file-id',
rid: 'test-room-id',
} as any;
const result = await FileUpload.requestCanAccessFiles(request, file);
expect(result).to.be.false;
});
it('should deny access if file object is not provided when using JWT', async () => {
settingsGetMap.set('FileUpload_ProtectFiles', true);
settingsGetMap.set('FileUpload_Enable_json_web_token_for_files', true);
settingsGetMap.set('FileUpload_json_web_token_secret_for_files', 'test-secret');
validateAndDecodeJWTStub.returns({ fileId: 'test-file-id', rid: 'test-room-id', userId: 'test-user-id' });
const request = {
headers: {},
url: '/file-upload/test-file-id/test-file.png?token=valid-token',
} as any;
const result = await FileUpload.requestCanAccessFiles(request, undefined);
expect(result).to.be.false;
});
it('should allow access when everything is valid: token is valid, secret configured, and file/room match', async () => {
settingsGetMap.set('FileUpload_ProtectFiles', true);
settingsGetMap.set('FileUpload_Enable_json_web_token_for_files', true);
settingsGetMap.set('FileUpload_json_web_token_secret_for_files', 'test-secret');
validateAndDecodeJWTStub.returns({ fileId: 'test-file-id', rid: 'test-room-id', userId: 'test-user-id' });
const request = {
headers: {},
url: '/file-upload/test-file-id/test-file.png?token=valid-token',
} as any;
const file = {
_id: 'test-file-id',
rid: 'test-room-id',
} as any;
const result = await FileUpload.requestCanAccessFiles(request, file);
expect(result).to.be.true;
expect(validateAndDecodeJWTStub.calledOnceWith('valid-token', 'test-secret')).to.be.true;
});
describe('livechat room-based authorization (rc_room_type=l)', () => {
it('should allow access when livechat credentials are valid and file belongs to the same room', async () => {
settingsGetMap.set('FileUpload_ProtectFiles', true);
const canAccessUploadedFileStub = sinon.stub().resolves(true);
roomCoordinatorStub.getRoomDirectives.returns({ canAccessUploadedFile: canAccessUploadedFileStub });
const request = {
headers: {},
url: '/file-upload/test-file-id/test-file.png?rc_room_type=l&rc_rid=room-1&rc_token=visitor-token',
} as any;
const file = { _id: 'test-file-id', rid: 'room-1' } as any;
const result = await FileUpload.requestCanAccessFiles(request, file);
expect(result).to.be.true;
expect(canAccessUploadedFileStub.calledOnce).to.be.true;
});
it('should deny access when livechat credentials are valid but file belongs to a different room', async () => {
settingsGetMap.set('FileUpload_ProtectFiles', true);
const canAccessUploadedFileStub = sinon.stub().resolves(false);
roomCoordinatorStub.getRoomDirectives.returns({ canAccessUploadedFile: canAccessUploadedFileStub });
const request = {
headers: {},
url: '/file-upload/victim-file-id/secret.txt?rc_room_type=l&rc_rid=room-attacker&rc_token=attacker-token',
} as any;
// File belongs to victim's room, not the attacker's room
const file = { _id: 'victim-file-id', rid: 'room-victim' } as any;
const result = await FileUpload.requestCanAccessFiles(request, file);
expect(result).to.be.false;
});
it('should pass the file object to canAccessUploadedFile', async () => {
settingsGetMap.set('FileUpload_ProtectFiles', true);
const canAccessUploadedFileStub = sinon.stub().resolves(true);
roomCoordinatorStub.getRoomDirectives.returns({ canAccessUploadedFile: canAccessUploadedFileStub });
const request = {
headers: {},
url: '/file-upload/test-file-id/test-file.png?rc_room_type=l&rc_rid=room-1&rc_token=visitor-token',
} as any;
const file = { _id: 'test-file-id', rid: 'room-1' } as any;
await FileUpload.requestCanAccessFiles(request, file);
const callArgs = canAccessUploadedFileStub.firstCall.args;
expect(callArgs[1]).to.deep.equal(file);
});
it('should deny access when rc_room_type is provided but canAccessUploadedFile returns false', async () => {
settingsGetMap.set('FileUpload_ProtectFiles', true);
const canAccessUploadedFileStub = sinon.stub().resolves(false);
roomCoordinatorStub.getRoomDirectives.returns({ canAccessUploadedFile: canAccessUploadedFileStub });
const request = {
headers: {},
url: '/file-upload/test-file-id/test-file.png?rc_room_type=l&rc_rid=room-1&rc_token=invalid-token',
} as any;
const file = { _id: 'test-file-id', rid: 'room-1' } as any;
const result = await FileUpload.requestCanAccessFiles(request, file);
expect(result).to.be.false;
});
});
});
describe('getRequestUserId', () => {
it('should return undefined when no url is provided', async () => {
const request = { headers: {}, url: undefined } as any;
const result = await FileUpload.getRequestUserId(request);
expect(result).to.be.undefined;
expect(usersModelStub.findOneByIdAndLoginToken.called).to.be.false;
});
it('should return undefined when no credentials are provided', async () => {
const request = { headers: {}, url: '/ufs/UserDataFiles/file-id' } as any;
const result = await FileUpload.getRequestUserId(request);
expect(result).to.be.undefined;
expect(usersModelStub.findOneByIdAndLoginToken.called).to.be.false;
});
it('should return undefined when a uid is provided without a token', async () => {
const request = { headers: { 'x-user-id': 'user-1' }, url: '/ufs/UserDataFiles/file-id' } as any;
const result = await FileUpload.getRequestUserId(request);
expect(result).to.be.undefined;
expect(usersModelStub.findOneByIdAndLoginToken.called).to.be.false;
});
it('should return undefined when the login token is invalid', async () => {
usersModelStub.findOneByIdAndLoginToken.resolves(null);
const request = { headers: { 'x-user-id': 'user-1', 'x-auth-token': 'bad-token' }, url: '/ufs/UserDataFiles/file-id' } as any;
const result = await FileUpload.getRequestUserId(request);
expect(result).to.be.undefined;
expect(usersModelStub.findOneByIdAndLoginToken.calledOnceWith('user-1', 'hashed_bad-token')).to.be.true;
});
it('should return the user id when credentials are valid via headers', async () => {
usersModelStub.findOneByIdAndLoginToken.resolves({ _id: 'user-1' });
const request = { headers: { 'x-user-id': 'user-1', 'x-auth-token': 'good-token' }, url: '/ufs/UserDataFiles/file-id' } as any;
const result = await FileUpload.getRequestUserId(request);
expect(result).to.equal('user-1');
expect(usersModelStub.findOneByIdAndLoginToken.calledOnceWith('user-1', 'hashed_good-token')).to.be.true;
});
it('should return the user id when credentials are valid via query string', async () => {
usersModelStub.findOneByIdAndLoginToken.resolves({ _id: 'user-1' });
const request = { headers: {}, url: '/ufs/UserDataFiles/file-id?rc_uid=user-1&rc_token=good-token' } as any;
const result = await FileUpload.getRequestUserId(request);
expect(result).to.equal('user-1');
expect(usersModelStub.findOneByIdAndLoginToken.calledOnceWith('user-1', 'hashed_good-token')).to.be.true;
});
});
describe('UserDataFiles.onRead', () => {
// eslint-disable-next-line new-cap
const getOnRead = () => FileUpload.defaults.UserDataFiles().onRead;
const createResponse = () => {
const res = { writeHead: sinon.stub(), setHeader: sinon.stub() };
res.writeHead.returns(res);
return res as any;
};
it('should deny access to an unauthenticated request', async () => {
const res = createResponse();
const file = { _id: 'file-id', userId: 'owner-1', name: 'export.zip' } as any;
const request = { headers: {}, url: '/ufs/UserDataFiles/file-id' } as any;
const result = await getOnRead()('file-id', file, request, res);
expect(result).to.be.false;
expect(res.writeHead.calledOnceWith(403)).to.be.true;
expect(res.setHeader.called).to.be.false;
});
it('should deny access to an authenticated user who is not the owner', async () => {
usersModelStub.findOneByIdAndLoginToken.resolves({ _id: 'attacker-1' });
const res = createResponse();
const file = { _id: 'file-id', userId: 'owner-1', name: 'export.zip' } as any;
const request = {
headers: { 'x-user-id': 'attacker-1', 'x-auth-token': 'attacker-token' },
url: '/ufs/UserDataFiles/file-id',
} as any;
const result = await getOnRead()('file-id', file, request, res);
expect(result).to.be.false;
expect(res.writeHead.calledOnceWith(403)).to.be.true;
expect(res.setHeader.called).to.be.false;
});
it('should allow access to the owner of the export', async () => {
usersModelStub.findOneByIdAndLoginToken.resolves({ _id: 'owner-1' });
const res = createResponse();
const file = { _id: 'file-id', userId: 'owner-1', name: 'export.zip' } as any;
const request = {
headers: { 'x-user-id': 'owner-1', 'x-auth-token': 'owner-token' },
url: '/ufs/UserDataFiles/file-id',
} as any;
const result = await getOnRead()('file-id', file, request, res);
expect(result).to.be.true;
expect(res.writeHead.called).to.be.false;
expect(res.setHeader.calledOnceWith('content-disposition', 'attachment; filename="export.zip"')).to.be.true;
});
});
});

View File

@@ -0,0 +1,936 @@
import { Buffer } from 'node:buffer';
import type { WriteStream } from 'node:fs';
import fs from 'node:fs';
import { unlink, rename, writeFile } from 'node:fs/promises';
import type * as http from 'node:http';
import type * as https from 'node:https';
import stream from 'node:stream';
import { finished } from 'node:stream/promises';
import URL from 'node:url';
import { isArrayBufferView } from 'node:util/types';
import { hashLoginToken } from '@rocket.chat/account-utils';
import { Apps, AppEvents } from '@rocket.chat/apps';
import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions';
import { isE2EEUpload, type IUpload } from '@rocket.chat/core-typings';
import { Users, Avatars, UserDataFiles, Uploads, Settings, Subscriptions, Messages, Rooms } from '@rocket.chat/models';
import { streamToBuffer } from '@rocket.chat/tools';
import type { NextFunction } from 'connect';
import filesize from 'filesize';
import { Match } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { Cookies } from 'meteor/ostrio:cookies';
import type { ClientSession, OptionalId } from 'mongodb';
import sharp from 'sharp';
import type { WritableStreamBuffer } from 'stream-buffers';
import streamBuffers from 'stream-buffers';
import { mime } from '../../../../../app/utils/lib/mimeTypes';
import { isRenderableImageType } from '../../../../../lib/renderableImageTypes';
import { MultipartUploadHandler } from '../../../../api/lib/MultipartUploadHandler';
import { settings } from '../../../../settings';
import { UploadFS } from '../../../../ufs';
import { ufsComplete } from '../../../../ufs/ufs-methods';
import type { Store, StoreOptions } from '../../../../ufs/ufs-store';
import { canAccessRoomAsync, canAccessRoomIdAsync } from '../../../authorization/canAccessRoom';
import { i18n } from '../../../i18n';
import { SystemLogger } from '../../../logger/system';
import { roomCoordinator } from '../../../rooms/roomCoordinator';
import { validateAndDecodeJWT, generateJWT } from '../../../utils/lib/JWTHelper';
import { fileUploadIsValidContentType } from '../../../utils/restrictions';
const cookie = new Cookies();
// Caps GIF frames decoded per thumbnail; limitInputPixels doesn't bound frame count.
const MAX_ANIMATED_THUMBNAIL_PAGES = 100;
let maxFileSize = 0;
settings.watch('FileUpload_MaxFileSize', async (value: string) => {
try {
maxFileSize = parseInt(value);
} catch (e) {
maxFileSize = (await Settings.findOneById('FileUpload_MaxFileSize'))?.packageValue as number;
}
});
const handlers: Record<string, FileUploadClass> = {};
const defaults: Record<string, () => Partial<StoreOptions>> = {
Uploads() {
return {
collection: Uploads,
filter: new UploadFS.Filter({
onCheck: FileUpload.validateFileUpload,
}),
getPath(file: IUpload) {
return `${settings.get('uniqueID')}/uploads/${file.rid}/${file.userId}/${file._id}`;
},
onValidate: FileUpload.uploadsOnValidate,
async onRead(_fileId: string, file: IUpload, req: http.IncomingMessage, res: http.ServerResponse) {
// Deprecated: Remove support to usf path
if (!(await FileUpload.requestCanAccessFiles(req, file))) {
res.writeHead(403);
return false;
}
res.setHeader('content-disposition', `attachment; filename="${encodeURIComponent(file.name || '')}"`);
return true;
},
};
},
Avatars() {
return {
collection: Avatars,
filter: new UploadFS.Filter({
onCheck: FileUpload.validateAvatarUpload,
}),
getPath(file: IUpload) {
const avatarFile = file.rid ? `room-${file.rid}` : file.userId;
return `${settings.get('uniqueID')}/avatars/${avatarFile}`;
},
onValidate: FileUpload.avatarsOnValidate,
onFinishUpload: FileUpload.avatarsOnFinishUpload,
};
},
UserDataFiles() {
return {
collection: UserDataFiles,
getPath(file: IUpload) {
return `${settings.get('uniqueID')}/uploads/userData/${file.userId}/${file._id}`;
},
onValidate: FileUpload.uploadsOnValidate,
async onRead(_fileId: string, file: IUpload, req: http.IncomingMessage, res: http.ServerResponse) {
// UserDataFiles are GDPR data exports — only the owner of the export may download it.
const uid = await FileUpload.getRequestUserId(req);
if (!uid || uid !== file.userId) {
res.writeHead(403);
return false;
}
res.setHeader('content-disposition', `attachment; filename="${encodeURIComponent(file.name || '')}"`);
return true;
},
};
},
};
export const FileUpload = {
handlers,
getPath(path = '') {
return `/file-upload/${path}`;
},
configureUploadsStore(store: string, name: string, options: any) {
const type = name.split(':').pop();
if (!type || !(type in FileUpload.defaults)) {
throw new Error('Store type undefined');
}
const stores = UploadFS.getStores();
delete stores[name];
return new UploadFS.store[store](
Object.assign(
{
name,
},
options,
FileUpload.defaults[type](),
),
);
},
async validateFileUpload(file: IUpload, content?: Buffer | string) {
const isFederationUpload = Boolean(file.federation?.mxcUri);
if (!isFederationUpload && !Match.test(file.rid, String)) {
return false;
}
// livechat users can upload files but they don't have an userId
const user = (file.userId && (await Users.findOne(file.userId))) || undefined;
const room = file.rid ? await Rooms.findOneById(file.rid) : undefined;
if (!isFederationUpload && !room) {
return false;
}
const directMessageAllowed = settings.get('FileUpload_Enabled_Direct');
const fileUploadAllowed = settings.get('FileUpload_Enabled');
if (!isFederationUpload && room && user?.type !== 'app' && (await canAccessRoomAsync(room, user, file)) !== true) {
return false;
}
const language = user?.language || 'en';
if (!fileUploadAllowed) {
const reason = i18n.t('FileUpload_Disabled', { lng: language });
throw new Meteor.Error('error-file-upload-disabled', reason);
}
if (room && !directMessageAllowed && room.t === 'd') {
const reason = i18n.t('File_not_allowed_direct_messages', { lng: language });
throw new Meteor.Error('error-direct-message-file-upload-not-allowed', reason);
}
// -1 maxFileSize means there is no limit
if (maxFileSize > -1 && (file.size || 0) > maxFileSize) {
const reason = i18n.t('File_exceeds_allowed_size_of_bytes', {
size: filesize(maxFileSize),
lng: language,
});
throw new Meteor.Error('error-file-too-large', reason);
}
if (!settings.get('E2E_Enable_Encrypt_Files') && isE2EEUpload(file)) {
const reason = i18n.t('Encrypted_file_not_allowed', { lng: language });
throw new Meteor.Error('error-invalid-file-type', reason);
}
// E2EE files should be of type application/octet-stream. no information about them should be disclosed on upload if they are encrypted
if (isE2EEUpload(file)) {
file.type = 'application/octet-stream';
}
// E2EE files are of type application/octet-stream, which is whitelisted for E2EE files
if (!fileUploadIsValidContentType(file?.type, isE2EEUpload(file) ? 'application/octet-stream' : undefined)) {
const reason = i18n.t('File_type_is_not_accepted', { lng: language });
throw new Meteor.Error('error-invalid-file-type', reason);
}
// App IPreFileUpload event hook
try {
await Apps.self?.triggerEvent(AppEvents.IPreFileUpload, { file, content });
} catch (error: any) {
if (error.name === AppsEngineException.name) {
throw new Meteor.Error('error-app-prevented', error.message);
}
throw error;
}
return true;
},
async validateAvatarUpload(file: IUpload) {
if (!Match.test(file.rid, String) && !Match.test(file.userId, String)) {
return false;
}
const user = file.uid ? await Users.findOne(file.uid, { projection: { language: 1 } }) : null;
const language = user?.language || 'en';
// accept only images the browser can display as an avatar
if (!isRenderableImageType(file.type)) {
const reason = i18n.t('File_type_is_not_accepted', { lng: language });
throw new Meteor.Error('error-invalid-file-type', reason);
}
// -1 maxFileSize means there is no limit
if (maxFileSize > -1 && (file.size || 0) > maxFileSize) {
const reason = i18n.t('File_exceeds_allowed_size_of_bytes', {
size: filesize(maxFileSize),
lng: language,
});
throw new Meteor.Error('error-file-too-large', reason);
}
return true;
},
defaults,
async avatarsOnValidate(this: Store, file: IUpload, options?: { session?: ClientSession }) {
if (settings.get('Accounts_AvatarResize') !== true) {
return;
}
const tempFilePath = UploadFS.getTempFilePath(file._id);
const height = settings.get('Accounts_AvatarSize') as number;
const width = height as number;
const s = sharp(tempFilePath);
if (settings.get('FileUpload_RotateImages') === true) {
s.rotate();
}
const metadata = await s.metadata();
// if (!metadata) {
// metadata = {};
// }
const { data, info } = await s
.resize({
width,
height,
fit: metadata.hasAlpha ? sharp.fit.contain : sharp.fit.cover,
background: { r: 255, g: 255, b: 255, alpha: metadata.hasAlpha ? 0 : 1 },
})
// Use buffer to get the result in memory then replace the existing file
// There is no option to override a file using this library
//
// BY THE SHARP DOCUMENTATION:
// toBuffer: Write output to a Buffer. JPEG, PNG, WebP, TIFF and RAW output are supported.
// By default, the format will match the input image, except GIF and SVG input which become PNG output.
.toBuffer({ resolveWithObject: true });
try {
await writeFile(tempFilePath, data);
} catch (err: any) {
SystemLogger.error({ err });
}
await this.getCollection().updateOne(
{ _id: file._id },
{
$set: {
size: info.size,
...(['gif', 'svg'].includes(metadata.format || '') ? { type: 'image/png' } : {}),
},
},
options,
);
},
async resizeImagePreview(fileParam: Pick<IUpload, '_id'>) {
let file = await Uploads.findOneById(fileParam._id);
if (!file) {
return;
}
file = FileUpload.addExtensionTo(file);
const image = await FileUpload.getStore('Uploads')._store.getReadStream(file._id, file);
const transformer = sharp().resize({ width: 32, height: 32, fit: 'inside' }).jpeg().blur();
const result = transformer.toBuffer().then((out) => out.toString('base64'));
image.pipe(transformer);
return result;
},
async extractMetadata(file: IUpload) {
return sharp(await FileUpload.getBuffer(file)).metadata();
},
async createImageThumbnail(fileParam: Pick<IUpload, '_id' | 'identify'>) {
if (!settings.get('Message_Attachments_Thumbnails_Enabled')) {
return;
}
const width = settings.get('Message_Attachments_Thumbnails_Width') as number;
const height = settings.get('Message_Attachments_Thumbnails_Height') as number;
if (fileParam.identify?.size && fileParam.identify.size.height < height && fileParam.identify?.size.width < width) {
return;
}
let file = await Uploads.findOneById(fileParam._id);
if (!file) {
return;
}
file = FileUpload.addExtensionTo(file);
const store = FileUpload.getStore('Uploads');
const image = await store._store.getReadStream(file._id, file);
let transformer = (
file.type === 'image/gif' ? sharp({ pages: Math.min(file.identify?.pages ?? 1, MAX_ANIMATED_THUMBNAIL_PAGES) }) : sharp()
).resize({ width, height, fit: 'inside' });
if (file.type === 'image/svg+xml') {
transformer = transformer.png();
}
// pageHeight is the per-frame height; info.height is the full stacked height for animated input.
const result = transformer.toBuffer({ resolveWithObject: true }).then(({ data, info: { width, height, pageHeight, format } }) => ({
data,
width,
height: pageHeight ?? height,
thumbFileType: (mime.lookup(format) as string) || '',
thumbFileName: file?.name as string,
originalFileId: file?._id as string,
}));
image.pipe(transformer);
return result;
},
async uploadImageThumbnail(
{ thumbFileName, thumbFileType, originalFileId }: { thumbFileName: string; thumbFileType: string; originalFileId: string },
buffer: Buffer,
rid: string,
userId: string,
) {
const store = FileUpload.getStore('Uploads');
const details = {
name: `thumb-${thumbFileName}`,
size: buffer.length,
type: thumbFileType,
originalFileId,
typeGroup: 'thumb',
uploadedAt: new Date(),
_updatedAt: new Date(),
rid,
userId,
};
return store.insert(details, buffer);
},
async uploadsOnValidate(this: Store, file: IUpload, options?: { session?: ClientSession }) {
if (!file.type || !/^image\/((x-windows-)?bmp|p?jpeg|png|gif|webp)$/.test(file.type)) {
return;
}
const tmpFile = UploadFS.getTempFilePath(file._id);
const s = sharp(tmpFile);
const metadata = await s.metadata();
const rotated = typeof metadata.orientation !== 'undefined' && metadata.orientation !== 1;
const width = rotated ? metadata.height : metadata.width;
const height = rotated ? metadata.width : metadata.height;
const identify = {
format: metadata.format,
size:
width != null && height != null
? {
width,
height,
}
: undefined,
pages: metadata.pages,
};
const shouldRotate = settings.get<boolean>('FileUpload_RotateImages');
const shouldStripExif = settings.get('Message_Attachments_Strip_Exif') === true;
let size = file.size || 0;
const reorientation = async () => {
// sharp rotates AND removes metadata
const transform = s.rotate();
if (!shouldStripExif) {
transform.withMetadata();
}
const result = await transform.toFile(`${tmpFile}.sharp`);
size = result.size;
await unlink(tmpFile);
await rename(`${tmpFile}.sharp`, tmpFile);
};
if (rotated && shouldRotate) {
// If there is EXIF orientation and the setting is enabled, rotate the image (which removes metadata)
await reorientation();
} else if (shouldStripExif) {
// If there is no EXIF orientation but the setting is enabled, still strip any metadata
size = await MultipartUploadHandler.stripExifFromFile(tmpFile);
}
await this.getCollection().updateOne(
{ _id: file._id },
{
$set: { size, identify },
},
options,
);
},
async avatarsOnFinishUpload(file: IUpload) {
if (file.rid) {
return;
}
if (!file.userId) {
throw new Meteor.Error('error-not-allowed', 'Change avatar is not allowed');
}
// update file record to match user's username
const user = await Users.findOneById(file.userId);
if (!user?.username) {
throw new Meteor.Error('error-not-allowed', 'Change avatar is not allowed');
}
const oldAvatar = await Avatars.findOneByName(user.username);
if (oldAvatar) {
await Avatars.deleteFile(oldAvatar._id);
}
await Avatars.updateFileNameById(file._id, user.username);
},
async getRequestUserId({ headers = {}, url }: http.IncomingMessage): Promise<string | undefined> {
if (!url) {
return undefined;
}
const { query } = URL.parse(url, true);
let { rc_uid, rc_token } = query as Record<string, string | undefined>;
if (!rc_uid && headers.cookie) {
rc_uid = cookie.get('rc_uid', headers.cookie);
rc_token = cookie.get('rc_token', headers.cookie);
}
const uid = rc_uid || (headers['x-user-id'] as string);
const authToken = rc_token || (headers['x-auth-token'] as string);
if (!uid || !authToken) {
return undefined;
}
const user = await Users.findOneByIdAndLoginToken(uid, hashLoginToken(authToken), { projection: { _id: 1 } });
return user?._id;
},
async requestCanAccessFiles({ headers = {}, url }: http.IncomingMessage, file?: IUpload) {
if (!url || !settings.get('FileUpload_ProtectFiles')) {
return true;
}
const { query } = URL.parse(url, true);
let { rc_uid, rc_token, rc_rid, rc_room_type } = query as Record<string, string | undefined>;
const { token } = query;
if (!rc_uid && headers.cookie) {
rc_uid = cookie.get('rc_uid', headers.cookie);
rc_token = cookie.get('rc_token', headers.cookie);
rc_rid = cookie.get('rc_rid', headers.cookie);
rc_room_type = cookie.get('rc_room_type', headers.cookie);
}
const isAuthorizedByRoom = async () =>
rc_room_type &&
roomCoordinator
.getRoomDirectives(rc_room_type)
.canAccessUploadedFile({ rc_uid: rc_uid || '', rc_rid: rc_rid || '', rc_token: rc_token || '' }, file);
const isAuthorizedByJWT: () => boolean = () => {
if (!token || typeof token !== 'string' || !settings.get('FileUpload_Enable_json_web_token_for_files')) {
return false;
}
if (!settings.get('FileUpload_json_web_token_secret_for_files')) {
SystemLogger.error('FileUpload_json_web_token_secret_for_files is not configured. Cannot validate JWT for file access.');
return false;
}
const payload = validateAndDecodeJWT(token, settings.get('FileUpload_json_web_token_secret_for_files'));
if (!payload) {
return false;
}
const { fileId, rid } = payload as { fileId: string; rid: string };
if (!fileId || !rid) {
return false;
}
if (fileId !== file?._id || rid !== file?.rid) {
return false;
}
return true;
};
if (isAuthorizedByJWT() || (await isAuthorizedByRoom())) {
return true;
}
const uid = rc_uid || (headers['x-user-id'] as string);
const authToken = rc_token || (headers['x-auth-token'] as string);
const user = uid && authToken && (await Users.findOneByIdAndLoginToken(uid, hashLoginToken(authToken), { projection: { _id: 1 } }));
if (!user) {
return false;
}
if (!file?.rid) {
return true;
}
const fileUploadRestrictedToMembers = settings.get<boolean>('FileUpload_Restrict_to_room_members');
const fileUploadRestrictToUsersWhoCanAccessRoom = settings.get<boolean>('FileUpload_Restrict_to_users_who_can_access_room');
if (!fileUploadRestrictToUsersWhoCanAccessRoom && !fileUploadRestrictedToMembers) {
return true;
}
if (fileUploadRestrictedToMembers && !fileUploadRestrictToUsersWhoCanAccessRoom) {
const sub = await Subscriptions.findOneByRoomIdAndUserId(file.rid, user._id, { projection: { _id: 1 } });
return !!sub;
}
if (fileUploadRestrictToUsersWhoCanAccessRoom && !fileUploadRestrictedToMembers) {
return canAccessRoomIdAsync(file.rid, user._id);
}
return false;
},
addExtensionTo(file: IUpload) {
if (mime.lookup(file.name || '') === file.type) {
return file;
}
// This file type can be pretty much anything, so it's better if we don't mess with the file extension
if (file.type !== 'application/octet-stream') {
const ext = mime.extension(file.type || '');
if (ext && new RegExp(`\\.${ext}$`, 'i').test(file.name || '') === false) {
file.name = `${file.name}.${ext}`;
}
}
return file;
},
getStore(modelName: string) {
const storageType = settings.get('FileUpload_Storage_Type');
const handlerName = `${storageType}:${modelName}`;
return this.getStoreByName(handlerName);
},
getStoreByName(handlerName?: string) {
if (!handlerName) {
SystemLogger.error({
msg: 'Empty Upload handler does not exists',
});
throw new Error(`Empty Upload handler does not exists`);
}
if (this.handlers[handlerName] == null) {
SystemLogger.error({
msg: 'Upload handler does not exists',
handlerName,
});
}
return this.handlers[handlerName];
},
get(file: IUpload, req: http.IncomingMessage, res: http.ServerResponse, next: NextFunction) {
const store = this.getStoreByName(file.store);
if (store?.get) {
return store.get(file, req, res, next);
}
res.writeHead(404);
res.end();
},
async getBuffer(file: IUpload): Promise<Buffer> {
const store = this.getStoreByName(file.store);
if (!store?.get) {
throw new Error('Store is invalid');
}
const buffer = new streamBuffers.WritableStreamBuffer({
initialSize: file.size,
});
return new Promise<Buffer>((resolve, reject) => {
buffer.on('error', reject);
buffer.on('finish', () => {
const contents = buffer.getContents();
if (contents === false) {
return reject();
}
resolve(contents);
});
void store.copy?.(file, buffer);
});
},
async copy(file: IUpload, targetFile: string) {
const store = this.getStoreByName(file.store);
const out = fs.createWriteStream(targetFile);
file = FileUpload.addExtensionTo(file);
if (store.copy) {
await store.copy(file, out);
return true;
}
return false;
},
redirectToFile(fileUrl: string, _req: http.IncomingMessage, res: http.ServerResponse) {
res.removeHeader('Content-Length');
res.removeHeader('Cache-Control');
res.setHeader('Location', fileUrl);
res.writeHead(302);
res.end();
},
respondWithRedirectUrlInfo(
redirectUrl: string | false,
file: IUpload,
_req: http.IncomingMessage,
res: http.ServerResponse,
expiresInSeconds?: number | null,
) {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.writeHead(200);
res.end(
JSON.stringify({
redirectUrl,
name: file.name,
type: file.type,
size: file.size,
...(expiresInSeconds && { expires: new Date(Date.now() + expiresInSeconds * 1000).toISOString() }),
}),
);
},
proxyFile(
fileName: string,
fileUrl: string,
forceDownload: boolean,
request: typeof http | typeof https,
_req: http.IncomingMessage,
res: http.ServerResponse,
) {
res.setHeader('Content-Disposition', `${forceDownload ? 'attachment' : 'inline'}; filename="${encodeURI(fileName)}"`);
request.get(fileUrl, (fileRes) => {
if (fileRes.statusCode !== 200) {
res.setHeader('x-rc-proxyfile-status', String(fileRes.statusCode));
res.setHeader('content-length', 0);
res.writeHead(500);
res.end();
return;
}
const headersToProxy = ['age', 'cache-control', 'content-length', 'content-type', 'date', 'expired', 'last-modified'];
headersToProxy.forEach((header) => {
fileRes.headers[header] && res.setHeader(header, String(fileRes.headers[header]));
});
fileRes.pipe(res);
});
},
generateJWTToFileUrls({ rid, userId, fileId }: { rid: string; userId: string; fileId: string }) {
if (
!settings.get('FileUpload_ProtectFiles') ||
!settings.get('FileUpload_Enable_json_web_token_for_files') ||
!settings.get('FileUpload_json_web_token_secret_for_files')
) {
return;
}
return generateJWT(
{
rid,
userId,
fileId,
},
settings.get('FileUpload_json_web_token_secret_for_files'),
);
},
async removeFilesByRoomId(rid: string) {
if (typeof rid !== 'string' || rid.trim().length === 0) {
return;
}
const cursor = Messages.find(
{
rid,
'files._id': {
$exists: true,
},
},
{
projection: {
'files._id': 1,
},
},
);
for await (const document of cursor) {
if (document.files) {
await Promise.all(document.files.map((file) => FileUpload.getStore('Uploads').deleteById(file._id)));
}
}
},
};
type FileUploadClassOptions = {
name: string;
model?: typeof Avatars | typeof Uploads | typeof UserDataFiles;
store?: Store;
get: (file: IUpload, req: http.IncomingMessage, res: http.ServerResponse, next: NextFunction) => Promise<void>;
insert?: () => Promise<IUpload>;
getStore?: () => Store;
copy?: (file: IUpload, out: WriteStream | WritableStreamBuffer) => Promise<void>;
};
export class FileUploadClass {
public name: FileUploadClassOptions['name'];
public model: typeof Avatars | typeof Uploads | typeof UserDataFiles;
public _store: Store;
public get: FileUploadClassOptions['get'];
public copy: FileUploadClassOptions['copy'];
constructor({ name, model, store, get, insert, getStore, copy }: FileUploadClassOptions) {
this.name = name;
this.model = model || this.getModelFromName();
this._store = store || UploadFS.getStore(name);
this.get = get;
this.copy = copy;
if (insert) {
this.insert = insert;
}
if (getStore) {
this.getStore = getStore;
}
FileUpload.handlers[name] = this;
}
getStore() {
return this._store;
}
get store() {
return this.getStore();
}
set store(store) {
this._store = store;
}
getModelFromName(): typeof this.model {
const modelsAvailable: Record<string, typeof this.model> = {
Avatars,
Uploads,
UserDataFiles,
};
const modelName = this.name.split(':')[1];
if (!modelsAvailable[modelName]) {
throw new Error('Invalid Model for FileUpload');
}
return modelsAvailable[modelName];
}
async delete(fileId: string, options?: { session?: ClientSession }) {
// TODO: Remove this method
if (this.store?.delete) {
await this.store.delete(fileId, { session: options?.session });
}
return this.model.deleteFile(fileId, { session: options?.session });
}
async deleteById(fileId: string) {
const file = await this.model.findOneById(fileId);
if (!file) {
return;
}
const store = FileUpload.getStoreByName(file.store);
return store.delete(file._id);
}
async deleteByName(fileName: string, options?: { session?: ClientSession }) {
const file = await this.model.findOneByName(fileName, { session: options?.session });
if (!file) {
return;
}
const store = FileUpload.getStoreByName(file.store);
return store.delete(file._id);
}
async deleteByRoomId(rid: string) {
const file = await this.model.findOneByRoomId(rid);
if (!file) {
return;
}
const store = FileUpload.getStoreByName(file.store);
return store.delete(file._id);
}
private async _validateFile(
fileData: Omit<OptionalId<IUpload>, '_updatedAt'>,
content: stream.Readable | Buffer | string,
): Promise<stream.Readable | Buffer | string> {
const filter = this.store.getFilter();
if (!filter?.check) {
return content;
}
if (content instanceof stream.Readable) {
// Currently, only the Slack Adapter passes a stream.Readable here
// We can't use _streamToTmpFile at this stage since the file hasn't been validated yet,
// and for security reasons we must not write it to disk before validation
content = await streamToBuffer(content);
} else if (content instanceof Uint8Array && !(content instanceof Buffer)) {
// Services compat - create a view into the underlying ArrayBuffer without copying the data
content = Buffer.from(content.buffer, content.byteOffset, content.byteLength);
}
try {
await filter.check(fileData, content);
return content;
} catch (e) {
throw e;
}
}
async _doInsert(
fileData: Omit<OptionalId<IUpload>, '_updatedAt'>,
content: stream.Readable | Buffer | string,
options?: { session?: ClientSession },
): Promise<IUpload> {
const fileId = await this.store.create(fileData, { session: options?.session });
const tmpFile = UploadFS.getTempFilePath(fileId);
try {
if (typeof content === 'string') {
await fs.promises.rename(content, tmpFile);
} else if (isArrayBufferView(content)) {
await fs.promises.writeFile(tmpFile, content);
} else if (content instanceof stream.Readable) {
await finished(content.pipe(fs.createWriteStream(tmpFile)), { cleanup: true });
} else {
throw new Error('Invalid file type');
}
return ufsComplete(fileId, this.name, { session: options?.session });
} catch (e) {
throw e;
}
}
async insert(
fileData: Omit<OptionalId<IUpload>, '_updatedAt'>,
streamOrBuffer: stream.Readable | Buffer | string,
options?: { session?: ClientSession },
): Promise<IUpload> {
streamOrBuffer = await this._validateFile(fileData, streamOrBuffer);
return this._doInsert(fileData, streamOrBuffer, options);
}
}

View File

@@ -0,0 +1,8 @@
import path from 'node:path';
import { UploadFS } from '../../../../ufs';
// set ufs temp dir to $TMPDIR/ufs instead of /tmp/ufs if the variable is set
if ('TMPDIR' in process.env) {
UploadFS.config.tmpDir = path.join(process.env.TMPDIR || '', 'ufs');
}

View File

@@ -0,0 +1,63 @@
import type http from 'node:http';
import type { IUpload } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { describe, it } from 'mocha';
import { getFileRange } from './ranges';
const req = (range?: string) => ({ headers: { range } }) as http.IncomingMessage;
const file = (size: number) => ({ size }) as IUpload;
describe('getFileRange', () => {
it('returns undefined when no Range header is present', () => {
expect(getFileRange(file(1000), req())).to.equal(undefined);
});
it('returns undefined for a malformed Range header', () => {
expect(getFileRange(file(1000), req('bytes=abc'))).to.equal(undefined);
expect(getFileRange(file(1000), req('items=0-99'))).to.equal(undefined);
expect(getFileRange(file(1000), req('bytes=-'))).to.equal(undefined);
});
it('returns undefined when the file has no size', () => {
expect(getFileRange(file(0), req('bytes=0-'))).to.equal(undefined);
});
it('serves an open-ended range `bytes=0-` as the whole file', () => {
expect(getFileRange(file(1000), req('bytes=0-'))).to.deep.equal({ outOfRange: false, start: 0, stop: 999 });
});
it('serves an open-ended range from a mid-file offset to EOF', () => {
expect(getFileRange(file(1000), req('bytes=77-'))).to.deep.equal({ outOfRange: false, start: 77, stop: 999 });
});
it('serves an explicit closed range', () => {
expect(getFileRange(file(1000), req('bytes=100-199'))).to.deep.equal({ outOfRange: false, start: 100, stop: 199 });
});
it('clamps an end that runs past EOF', () => {
expect(getFileRange(file(1000), req('bytes=100-5000'))).to.deep.equal({ outOfRange: false, start: 100, stop: 999 });
});
it('serves a suffix range `bytes=-N` as the last N bytes', () => {
expect(getFileRange(file(1000), req('bytes=-200'))).to.deep.equal({ outOfRange: false, start: 800, stop: 999 });
});
it('clamps a suffix range larger than the file to the whole file', () => {
expect(getFileRange(file(1000), req('bytes=-5000'))).to.deep.equal({ outOfRange: false, start: 0, stop: 999 });
});
it('flags a start at or beyond EOF as out of range', () => {
expect(getFileRange(file(1000), req('bytes=1000-'))).to.deep.equal({ outOfRange: true, start: 1000, stop: 999 });
expect(getFileRange(file(1000), req('bytes=1500-1600'))).to.deep.equal({ outOfRange: true, start: 1500, stop: 999 });
});
it('flags an inverted closed range as out of range', () => {
expect(getFileRange(file(1000), req('bytes=200-100'))).to.deep.equal({ outOfRange: true, start: 200, stop: 100 });
});
it('serves the final byte of the file', () => {
expect(getFileRange(file(1000), req('bytes=999-'))).to.deep.equal({ outOfRange: false, start: 999, stop: 999 });
});
});

View File

@@ -0,0 +1,81 @@
import type http from 'node:http';
import type { IUpload } from '@rocket.chat/core-typings';
type ByteRange = { start?: number; stop?: number };
function parseByteRange(header?: string): ByteRange | undefined {
if (!header) {
return;
}
const matches = header.match(/^bytes=(\d*)-(\d*)$/);
if (!matches) {
return;
}
const [, startStr, stopStr] = matches;
if (startStr === '' && stopStr === '') {
return;
}
return {
start: startStr === '' ? undefined : parseInt(startStr, 10),
stop: stopStr === '' ? undefined : parseInt(stopStr, 10),
};
}
export function getFileRange(file: IUpload, req: http.IncomingMessage) {
const parsed = parseByteRange(req.headers.range);
if (!parsed) {
return;
}
const size = file.size || 0;
if (size <= 0) {
return;
}
let { start, stop } = parsed;
if (start === undefined) {
if (!stop) {
return { outOfRange: true, start: 0, stop: size - 1 };
}
start = Math.max(0, size - stop);
stop = size - 1;
} else if (stop === undefined || stop > size - 1) {
stop = size - 1;
}
if (start > stop || start >= size) {
return { outOfRange: true, start, stop };
}
return { outOfRange: false, start, stop };
}
// code from: https://github.com/jalik/jalik-ufs/blob/master/ufs-server.js#L310
export const setRangeHeaders = function (
range: { start: number; stop: number; outOfRange?: boolean } | undefined,
file: IUpload,
res: http.ServerResponse,
) {
if (!range) {
return;
}
if (range.outOfRange) {
// out of range request, return 416
res.removeHeader('Content-Length');
res.removeHeader('Content-Type');
res.removeHeader('Content-Disposition');
res.removeHeader('Last-Modified');
res.setHeader('Content-Range', `bytes */${file.size}`);
res.writeHead(416);
res.end();
return;
}
res.setHeader('Content-Range', `bytes ${range.start}-${range.stop}/${file.size}`);
res.removeHeader('Content-Length');
res.setHeader('Content-Length', range.stop - range.start + 1);
res.statusCode = 206;
};

View File

@@ -0,0 +1,62 @@
import type { IncomingMessage } from 'node:http';
import { Uploads } from '@rocket.chat/models';
import { WebApp } from 'meteor/webapp';
import { FileUpload } from './FileUpload';
import { SystemLogger } from '../../../logger/system';
const hasReplyWithRedirectUrlParam = (req: IncomingMessage) => {
if (!req.url) {
return false;
}
const [, params] = req.url.split('?');
if (!params) {
return false;
}
const searchParams = new URLSearchParams(params);
const replyWithRedirectUrl = searchParams.get('replyWithRedirectUrl');
return replyWithRedirectUrl === 'true' || replyWithRedirectUrl === '1';
};
WebApp.connectHandlers.use(FileUpload.getPath(), async (req, res, next) => {
const match = /^\/([^\/]+)\/(.*)/.exec(req.url || '');
if (match?.[1]) {
const file = await Uploads.findOneById(match[1]);
if (file) {
if (!(await FileUpload.requestCanAccessFiles(req, file))) {
res.writeHead(403);
res.end();
return;
}
if (hasReplyWithRedirectUrlParam(req)) {
if (!file.store) {
res.writeHead(404);
res.end();
return;
}
const store = FileUpload.getStoreByName(file.store);
let url: string | false = false;
let expiryTimespan: number | null = null;
try {
url = await store.getStore().getRedirectURL(file, false);
expiryTimespan = await store.getStore().getUrlExpiryTimeSpan();
} catch (err) {
SystemLogger.debug({ err });
}
return FileUpload.respondWithRedirectUrlInfo(url, file, req, res, expiryTimespan);
}
res.setHeader('Content-Security-Policy', "default-src 'none'");
res.setHeader('Cache-Control', 'max-age=31536000');
await FileUpload.get(file, req, res, next);
return;
}
}
res.writeHead(404);
res.end();
});

View File

@@ -0,0 +1,16 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';
import { MIN_URL_EXPIRY_TIME_SPAN_SECONDS, URL_EXPIRY_FALLBACK_SECONDS, getUrlExpiryTimeSpanWithFallback } from './urlExpiry';
describe('getUrlExpiryTimeSpanWithFallback', () => {
it('uses fallback when configured value is below minimum', () => {
expect(getUrlExpiryTimeSpanWithFallback(0)).to.equal(URL_EXPIRY_FALLBACK_SECONDS);
expect(getUrlExpiryTimeSpanWithFallback(3)).to.equal(URL_EXPIRY_FALLBACK_SECONDS);
});
it('uses configured value when at or above minimum', () => {
expect(getUrlExpiryTimeSpanWithFallback(MIN_URL_EXPIRY_TIME_SPAN_SECONDS)).to.equal(MIN_URL_EXPIRY_TIME_SPAN_SECONDS);
expect(getUrlExpiryTimeSpanWithFallback(300)).to.equal(300);
});
});

View File

@@ -0,0 +1,6 @@
export const MIN_URL_EXPIRY_TIME_SPAN_SECONDS = 5;
export const URL_EXPIRY_FALLBACK_SECONDS = 900;
export const getUrlExpiryTimeSpanWithFallback = (configuredValue: number): number => {
return configuredValue >= MIN_URL_EXPIRY_TIME_SPAN_SECONDS ? configuredValue : URL_EXPIRY_FALLBACK_SECONDS;
};

View File

@@ -0,0 +1,212 @@
import stream from 'node:stream';
import {
DeleteObjectCommand,
GetObjectCommand,
S3Client,
type GetObjectCommandInput,
type PutObjectCommandInput,
type S3ClientConfig,
} from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import type { IUpload } from '@rocket.chat/core-typings';
import { Random } from '@rocket.chat/random';
import { check } from 'meteor/check';
import _ from 'underscore';
import { UploadFS } from '../../../../../ufs';
import type { StoreOptions } from '../../../../../ufs/ufs-store';
import { SystemLogger } from '../../../../logger/system';
import { getUrlExpiryTimeSpanWithFallback } from '../../lib/urlExpiry';
export type S3Options = StoreOptions & {
connection: S3ClientConfig;
params: {
Bucket: string;
ACL: string;
};
URLExpiryTimeSpan: number;
getPath: (file: Omit<IUpload, '_updatedAt'>) => string;
};
class AmazonS3Store extends UploadFS.Store {
protected getPath: (file: Omit<IUpload, '_updatedAt'>) => string;
constructor(options: S3Options) {
// Default options
// options.secretAccessKey,
// options.accessKeyId,
// options.region,
// options.sslEnabled // optional
options = _.extend(
{
httpOptions: {
timeout: 6000,
agent: false,
},
},
options,
);
super(options);
const classOptions = options;
const customUserAgent = process.env.FILE_STORAGE_CUSTOM_USER_AGENT?.trim();
const s3 = new S3Client({
...options.connection,
...(customUserAgent && { customUserAgent }),
});
options.getPath =
options.getPath ||
function (file) {
return file._id;
};
this.getPath = function (file) {
if (file.AmazonS3) {
return file.AmazonS3.path;
}
// Compatibility
// TODO: Migration
if (file.s3) {
return file.s3.path + file._id;
}
return file._id;
};
this.getRedirectURL = async (file, forceDownload = false) => {
const expiresIn = getUrlExpiryTimeSpanWithFallback(classOptions.URLExpiryTimeSpan);
return getSignedUrl(
s3,
new GetObjectCommand({
Key: this.getPath(file),
ResponseContentDisposition: `${forceDownload ? 'attachment' : 'inline'}; filename="${encodeURI(file.name || '')}"`,
Bucket: classOptions.params.Bucket,
}),
{ expiresIn },
);
};
/**
* Creates the file in the collection
*/
this.create = async (file) => {
check(file, Object);
if (file._id == null) {
file._id = Random.id();
}
file.AmazonS3 = {
path: classOptions.getPath(file as Omit<IUpload, '_updatedAt'>),
};
file.store = this.options.name; // assign store to file
return (await this.getCollection().insertOne(file)).insertedId;
};
/**
* Removes the file
*/
this.delete = async function (fileId) {
const file = await this.getCollection().findOne({ _id: fileId });
if (!file) {
throw new Error('File not found');
}
try {
return await s3.send(
new DeleteObjectCommand({
Key: this.getPath(file),
Bucket: classOptions.params.Bucket,
}),
);
} catch (error) {
SystemLogger.error({ error, key: this.getPath(file), bucket: classOptions.params.Bucket });
throw error;
}
};
/**
* Returns the file read stream
*/
this.getReadStream = async function (_fileId, file, options = {}) {
const params: GetObjectCommandInput = {
Key: this.getPath(file),
Bucket: classOptions.params.Bucket,
};
if (options.start != null && options.end != null) {
params.Range = `bytes=${options.start}-${options.end}`;
}
const response = await s3.send(new GetObjectCommand(params));
if (!response.Body) {
throw new Error('File not found');
}
if (!('readable' in response.Body)) {
throw new Error('Response body is not a readable stream');
}
return response.Body;
};
/**
* Returns the file write stream
*/
this.getWriteStream = async function (_fileId, file /* , options*/) {
const writeStream = new stream.PassThrough();
writeStream.on('newListener', (event, listener) => {
if (event === 'finish') {
process.nextTick(() => {
writeStream.removeListener(event, listener);
writeStream.on('real_finish', listener);
});
}
});
const uploadParams: PutObjectCommandInput = {
Key: this.getPath(file),
Body: writeStream,
Bucket: classOptions.params.Bucket,
...(file.type && { ContentType: file.type }),
...(file.size != null && { ContentLength: file.size }),
...(classOptions.params.ACL && { ACL: classOptions.params.ACL as PutObjectCommandInput['ACL'] }),
};
const upload = new Upload({
client: s3,
params: uploadParams,
});
upload
.done()
.then(() => {
writeStream.emit('real_finish');
})
.catch((error) => {
SystemLogger.error({ err: error });
writeStream.emit('error', error);
});
return writeStream;
};
this.getUrlExpiryTimeSpan = async () => {
return getUrlExpiryTimeSpanWithFallback(classOptions.URLExpiryTimeSpan);
};
}
}
// Add store to UFS namespace
UploadFS.store.AmazonS3 = AmazonS3Store;

View File

@@ -0,0 +1,163 @@
import type { GetSignedUrlConfig } from '@google-cloud/storage';
import { Storage } from '@google-cloud/storage';
import type { IUpload } from '@rocket.chat/core-typings';
import { Random } from '@rocket.chat/random';
import { check } from 'meteor/check';
import { UploadFS } from '../../../../../ufs';
import type { StoreOptions } from '../../../../../ufs/ufs-store';
import { SystemLogger } from '../../../../logger/system';
import { getUrlExpiryTimeSpanWithFallback } from '../../lib/urlExpiry';
type GStoreOptions = StoreOptions & {
connection: {
credentials: {
client_email: string;
private_key: string;
projectId: string;
};
};
bucket: string;
URLExpiryTimeSpan: number;
getPath: (file: Omit<IUpload, '_updatedAt'>) => string;
};
class GoogleStorageStore extends UploadFS.Store {
protected getPath: (file: Omit<IUpload, '_updatedAt'>) => string;
constructor(options: GStoreOptions) {
super(options);
const userAgent = process.env.FILE_STORAGE_CUSTOM_USER_AGENT?.trim();
const gcs = new Storage({
...(userAgent && { userAgent }),
...options.connection,
});
const bucket = gcs.bucket(options.bucket);
options.getPath =
options.getPath ||
function (file) {
return file._id;
};
this.getPath = function (file) {
if (file.GoogleStorage) {
return file.GoogleStorage.path;
}
// Compatibility
// TODO: Migration
if (file.googleCloudStorage) {
return file.googleCloudStorage.path + file._id;
}
return file._id;
};
this.getRedirectURL = async (file, forceDownload = false) => {
const expirySeconds = getUrlExpiryTimeSpanWithFallback(options.URLExpiryTimeSpan);
const params: GetSignedUrlConfig = {
action: 'read',
responseDisposition: forceDownload ? 'attachment' : 'inline',
version: 'v2',
expires: Date.now() + expirySeconds * 1000,
};
const res = await bucket.file(this.getPath(file)).getSignedUrl(params);
return res[0];
};
this.getUrlExpiryTimeSpan = async () => {
return getUrlExpiryTimeSpanWithFallback(options.URLExpiryTimeSpan);
};
/**
* Creates the file in the collection
* @param file
* @param callback
* @return {string}
*/
this.create = async function (file) {
check(file, Object);
if (file._id == null) {
file._id = Random.id();
}
file.GoogleStorage = {
path: options.getPath(file as Omit<IUpload, '_updatedAt'>),
};
file.store = this.options.name; // assign store to file
return (await this.getCollection().insertOne(file)).insertedId;
};
/**
* Removes the file
* @param fileId
* @param callback
*/
this.delete = async function (fileId) {
// TODO
const file = await this.getCollection().findOne({ _id: fileId });
if (!file) {
throw new Error('File not found');
}
try {
return bucket.file(this.getPath(file)).delete();
} catch (err: any) {
SystemLogger.error({ err });
}
};
/**
* Returns the file read stream
* @param fileId
* @param file
* @param options
* @return {*}
*/
this.getReadStream = async function (_fileId, file, options = {}) {
const config: {
start?: number;
end?: number;
} = {};
if (options.start != null) {
config.start = options.start;
}
if (options.end != null) {
config.end = options.end;
}
return bucket.file(this.getPath(file)).createReadStream(config);
};
/**
* Returns the file write stream
* @param fileId
* @param file
* @param options
* @return {*}
*/
this.getWriteStream = async function (_fileId, file /* , options*/) {
return bucket.file(this.getPath(file)).createWriteStream({
gzip: false,
metadata: {
contentType: file.type,
contentDisposition: `inline; filename=${file.name}`,
// metadata: {
// custom: 'metadata'
// }
},
});
};
}
}
// Add store to UFS namespace
UploadFS.store.GoogleStorage = GoogleStorageStore;

View File

@@ -0,0 +1,154 @@
import stream from 'node:stream';
import type { IUpload } from '@rocket.chat/core-typings';
import { Random } from '@rocket.chat/random';
import { check } from 'meteor/check';
import { WebdavClientAdapter } from '../../../../../bridges/webdav/lib/webdavClientAdapter';
import { UploadFS } from '../../../../../ufs';
import type { StoreOptions } from '../../../../../ufs/ufs-store';
import { SystemLogger } from '../../../../logger/system';
type WebdavOptions = StoreOptions & {
connection: {
credentials: {
server: string;
username: string;
password: string;
};
};
uploadFolderPath: string;
getPath: (file: Omit<IUpload, '_updatedAt'>) => string;
};
class WebdavStore extends UploadFS.Store {
protected getPath: (file: Omit<IUpload, '_updatedAt'>) => string;
constructor(options: WebdavOptions) {
super(options);
const { server, username, password } = options.connection.credentials;
const client = new WebdavClientAdapter(server, { username, password });
options.getPath = function (file) {
if (options.uploadFolderPath[options.uploadFolderPath.length - 1] !== '/') {
options.uploadFolderPath += '/';
}
return options.uploadFolderPath + file._id;
};
client.stat(options.uploadFolderPath).catch((err) => {
if (err.message.toLowerCase() === 'not found') {
void client.createDirectory(options.uploadFolderPath);
} else if (err.message.toLowerCase() === 'unauthorized') {
console.warn('File upload is unauthorized to connect on Webdav, please verify your credentials');
}
});
/**
* Returns the file path
* @param file
* @return {string}
*/
this.getPath = function (file) {
if (file.Webdav) {
return file.Webdav.path;
}
return file._id;
};
/**
* Creates the file in the col lection
* @param file
* @param callback
* @return {string}
*/
this.create = async function (file) {
check(file, Object);
if (file._id == null) {
file._id = Random.id();
}
file.Webdav = {
path: options.getPath(file as Omit<IUpload, '_updatedAt'>),
};
file.store = this.options.name;
return (await this.getCollection().insertOne(file)).insertedId;
};
/**
* Removes the file
* @param fileId
* @param callback
*/
this.delete = async function (fileId) {
const file = await this.getCollection().findOne({ _id: fileId });
if (!file) {
throw new Error('File no found');
}
try {
return client.deleteFile(this.getPath(file));
} catch (err: any) {
SystemLogger.error({ err });
}
};
/**
* Returns the file read stream
* @param fileId
* @param file
* @param options
* @return {*}
*/
this.getReadStream = async function (_fileId, file, options = {}) {
const range: {
start?: number;
end?: number;
} = {};
if (options.start != null) {
range.start = options.start;
}
if (options.end != null) {
range.end = options.end;
}
return client.createReadStream(this.getPath(file), options);
};
/**
* Returns the file write stream
* @param fileId
* @param file
* @return {*}
*/
this.getWriteStream = async function (_fileId, file) {
const writeStream = new stream.PassThrough();
const webdavStream = client.createWriteStream(this.getPath(file), file.size || 0);
// TODO remove timeout when UploadFS bug resolved
const newListenerCallback = (event: string, listener: () => any) => {
if (event === 'finish') {
process.nextTick(() => {
writeStream.removeListener(event, listener);
writeStream.removeListener('newListener', newListenerCallback);
writeStream.on(event, () => {
setTimeout(listener, 500);
});
});
}
};
writeStream.on('newListener', newListenerCallback);
writeStream.pipe(webdavStream);
return writeStream;
};
}
}
// Add store to UFS namespace
UploadFS.store.Webdav = WebdavStore;

View File

@@ -0,0 +1,227 @@
import type { ReadStream } from 'node:fs';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { Readable } from 'node:stream';
import type { ObjectId } from 'bson';
import { MongoInternals } from 'meteor/mongo';
import { NpmModuleMongodb } from 'meteor/npm-mongo';
import mime from 'mime-type/with-db';
import mkdirp from 'mkdirp';
import { sanitizeFileName } from './functions/sanitizeFileName';
const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo;
type IFile = {
buffer: Buffer;
contentType?: string;
length: number;
uploadDate?: Date;
};
interface IRocketChatFileStore {
remove(fileId: string): Promise<void>;
createWriteStream(fileName: string, contentType: string): void;
createReadStream(fileName: string): void;
getFileWithReadStream(fileName: string): Promise<
| {
readStream: NpmModuleMongodb.GridFSBucketReadStream | ReadStream;
contentType?: string;
length: number;
uploadDate?: Date;
}
| undefined
>;
getFile(fileName: string): Promise<IFile | undefined>;
deleteFile(fileName: string): Promise<void>;
}
class GridFS implements IRocketChatFileStore {
private name: string;
private bucket: NpmModuleMongodb.GridFSBucket;
constructor({ name = 'file' } = {}) {
this.name = name;
this.bucket = new NpmModuleMongodb.GridFSBucket(db as any, { bucketName: this.name });
}
private async findOne(filename: string) {
const file = await this.bucket.find({ filename }).limit(1).toArray();
if (!file) {
return;
}
return file[0];
}
async remove(fileId: string) {
await this.bucket.delete(fileId as unknown as ObjectId);
}
createWriteStream(fileName: string, contentType: string) {
const ws = this.bucket.openUploadStream(fileName, {
contentType,
});
ws.on('close', () => {
return ws.emit('end');
});
return ws;
}
createReadStream(fileName: string) {
return this.bucket.openDownloadStreamByName(fileName);
}
async getFileWithReadStream(fileName: string) {
const file = await this.findOne(fileName);
if (!file) {
return;
}
const rs = this.createReadStream(fileName);
return {
readStream: rs,
contentType: file.contentType,
length: file.length,
uploadDate: file.uploadDate,
};
}
async getFile(fileName: string) {
const file = await this.getFileWithReadStream(fileName);
if (!file) {
return;
}
return new Promise<IFile>((resolve) => {
const data: Buffer[] = [];
file.readStream.on('data', (chunk) => {
return data.push(chunk);
});
file.readStream.on('end', () => {
resolve({
buffer: Buffer.concat(data),
contentType: file.contentType,
length: file.length,
uploadDate: file.uploadDate,
});
});
});
}
async deleteFile(fileName: string) {
const file = await this.findOne(fileName);
if (file == null) {
return undefined;
}
return this.remove(file._id as unknown as string);
}
}
class FileSystem implements IRocketChatFileStore {
private absolutePath: string;
constructor({ absolutePath = '~/uploads' } = {}) {
if (absolutePath.split(path.sep)[0] === '~') {
const homepath = process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE;
if (homepath != null) {
absolutePath = absolutePath.replace('~', homepath);
} else {
throw new Error('Unable to resolve "~" in path');
}
}
this.absolutePath = path.resolve(absolutePath);
mkdirp.sync(this.absolutePath);
}
createWriteStream(fileName: string) {
const ws = fs.createWriteStream(path.join(this.absolutePath, sanitizeFileName(fileName)));
ws.on('close', () => {
return ws.emit('end');
});
return ws;
}
createReadStream(fileName: string) {
return fs.createReadStream(path.join(this.absolutePath, sanitizeFileName(fileName)));
}
stat(fileName: string) {
return fsp.stat(path.join(this.absolutePath, sanitizeFileName(fileName)));
}
async remove(fileName: string) {
return fsp.unlink(path.join(this.absolutePath, sanitizeFileName(fileName)));
}
async getFileWithReadStream(fileName: string) {
try {
const stat = await this.stat(fileName);
const rs = this.createReadStream(fileName);
return {
readStream: rs,
// We currently don't store the content type of uploaded custom sounds when using
// The filesystem storage. We will use mime to infer its type from the extension.
contentType: (mime.lookup(fileName) as string) || 'application/octet-stream',
length: stat.size,
};
} catch (error1) {
console.error(error1);
}
}
async getFile(fileName: string) {
const file = await this.getFileWithReadStream(fileName);
if (!file) {
return;
}
return new Promise<IFile>((resolve) => {
const data: Buffer[] = [];
file.readStream.on('data', (chunk) => {
if (Buffer.isBuffer(chunk)) {
return data.push(chunk);
}
return data.push(Buffer.from(chunk));
});
file.readStream.on('end', () => {
resolve({
buffer: Buffer.concat(data),
length: file.length,
});
});
});
}
async deleteFile(fileName: string) {
try {
return await this.remove(fileName);
} catch (error1) {
console.error(error1);
}
}
}
export const RocketChatFile = {
bufferToStream(buffer: Buffer) {
return Readable.from(buffer);
},
dataURIParse(dataURI: string | Buffer) {
const imageData = (Buffer.isBuffer(dataURI) ? dataURI : Buffer.from(dataURI)).toString().split(';base64,');
return {
image: imageData[1],
contentType: imageData[0].replace('data:', ''),
};
},
GridFS,
FileSystem,
};

View File

@@ -0,0 +1,55 @@
import { expect } from 'chai';
import { describe, it } from 'mocha';
import { sanitizeFileName } from './sanitizeFileName';
describe('sanitizeFileName', () => {
describe('valid filenames', () => {
it('should allow simple filenames', () => {
const result = sanitizeFileName('sound.mp3');
expect(result).to.equal('sound.mp3');
});
it('should allow filenames with dashes, underscores and dots', () => {
const result = sanitizeFileName('alert_test-01.wav');
expect(result).to.equal('alert_test-01.wav');
});
it('should allow filenames without extension', () => {
const result = sanitizeFileName('beep');
expect(result).to.equal('beep');
});
});
describe('invalid paths', () => {
it('should reject path traversal using ../', () => {
expect(() => sanitizeFileName('../etc/passwd')).to.throw();
});
it('should reject path traversal using ./', () => {
expect(() => sanitizeFileName('./folder/sounds')).to.throw();
});
it('should reject nested paths', () => {
expect(() => sanitizeFileName('sounds/alert.mp3')).to.throw();
});
it('should reject absolute paths', () => {
expect(() => sanitizeFileName('/etc/passwd')).to.throw();
});
});
describe('invalid characters', () => {
it('should reject filenames with spaces', () => {
expect(() => sanitizeFileName('my sound.mp3')).to.throw();
});
it('should reject filenames with special characters', () => {
expect(() => sanitizeFileName('sound$.mp3')).to.throw();
});
it('should reject filenames with backslashes', () => {
expect(() => sanitizeFileName('..\\passwd')).to.throw();
});
});
});

View File

@@ -0,0 +1,19 @@
import path from 'node:path';
export function sanitizeFileName(fileName: string) {
const base = path.basename(fileName);
if (base !== fileName) {
throw new Error('error-invalid-file-name');
}
if (base === '.' || base.startsWith('..')) {
throw new Error('error-invalid-file-name');
}
if (!/^[a-zA-Z0-9._-]+$/.test(base)) {
throw new Error('error-invalid-characters-in-file-name');
}
return base;
}

View File

@@ -0,0 +1,3 @@
import { RocketChatFile } from './file.server';
export { RocketChatFile };