snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
480
apps/meteor/server/lib/media/assets/assets.ts
Normal file
480
apps/meteor/server/lib/media/assets/assets.ts
Normal 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);
|
||||
1
apps/meteor/server/lib/media/assets/index.ts
Normal file
1
apps/meteor/server/lib/media/assets/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { RocketChatAssets, refreshClients } from './assets';
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
13
apps/meteor/server/lib/media/emoji-native/callbacks.ts
Normal file
13
apps/meteor/server/lib/media/emoji-native/callbacks.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
42
apps/meteor/server/lib/media/emoji-native/lib.ts
Normal file
42
apps/meteor/server/lib/media/emoji-native/lib.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
129
apps/meteor/server/lib/media/file-upload/config/AmazonS3.ts
Normal file
129
apps/meteor/server/lib/media/file-upload/config/AmazonS3.ts
Normal 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);
|
||||
144
apps/meteor/server/lib/media/file-upload/config/FileSystem.ts
Normal file
144
apps/meteor/server/lib/media/file-upload/config/FileSystem.ts
Normal 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];
|
||||
});
|
||||
@@ -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);
|
||||
211
apps/meteor/server/lib/media/file-upload/config/GridFS.ts
Normal file
211
apps/meteor/server/lib/media/file-upload/config/GridFS.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
74
apps/meteor/server/lib/media/file-upload/config/Webdav.ts
Normal file
74
apps/meteor/server/lib/media/file-upload/config/Webdav.ts
Normal 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);
|
||||
@@ -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);
|
||||
21
apps/meteor/server/lib/media/file-upload/config/helper.ts
Normal file
21
apps/meteor/server/lib/media/file-upload/config/helper.ts
Normal 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';
|
||||
};
|
||||
6
apps/meteor/server/lib/media/file-upload/index.ts
Normal file
6
apps/meteor/server/lib/media/file-upload/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import './lib/FileUploadBase';
|
||||
import { FileUpload } from './lib/FileUpload';
|
||||
import './lib/requests';
|
||||
import './config/_configUploadStorage';
|
||||
|
||||
export { FileUpload };
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
471
apps/meteor/server/lib/media/file-upload/lib/FileUpload.spec.ts
Normal file
471
apps/meteor/server/lib/media/file-upload/lib/FileUpload.spec.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
936
apps/meteor/server/lib/media/file-upload/lib/FileUpload.ts
Normal file
936
apps/meteor/server/lib/media/file-upload/lib/FileUpload.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
63
apps/meteor/server/lib/media/file-upload/lib/ranges.spec.ts
Normal file
63
apps/meteor/server/lib/media/file-upload/lib/ranges.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
81
apps/meteor/server/lib/media/file-upload/lib/ranges.ts
Normal file
81
apps/meteor/server/lib/media/file-upload/lib/ranges.ts
Normal 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;
|
||||
};
|
||||
62
apps/meteor/server/lib/media/file-upload/lib/requests.ts
Normal file
62
apps/meteor/server/lib/media/file-upload/lib/requests.ts
Normal 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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
212
apps/meteor/server/lib/media/file-upload/ufs/AmazonS3/server.ts
Normal file
212
apps/meteor/server/lib/media/file-upload/ufs/AmazonS3/server.ts
Normal 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;
|
||||
@@ -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;
|
||||
154
apps/meteor/server/lib/media/file-upload/ufs/Webdav/server.ts
Normal file
154
apps/meteor/server/lib/media/file-upload/ufs/Webdav/server.ts
Normal 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;
|
||||
227
apps/meteor/server/lib/media/file/file.server.ts
Normal file
227
apps/meteor/server/lib/media/file/file.server.ts
Normal 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,
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
3
apps/meteor/server/lib/media/file/index.ts
Normal file
3
apps/meteor/server/lib/media/file/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { RocketChatFile } from './file.server';
|
||||
|
||||
export { RocketChatFile };
|
||||
Reference in New Issue
Block a user