The communications platform that puts data protection first.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
Rocket.Chat/app/file-upload/server/lib/FileUpload.js

657 lines
18 KiB

import fs from 'fs';
import stream from 'stream';
import { Meteor } from 'meteor/meteor';
import streamBuffers from 'stream-buffers';
import Future from 'fibers/future';
import sharp from 'sharp';
import { Cookies } from 'meteor/ostrio:cookies';
import { UploadFS } from 'meteor/jalik:ufs';
import { Match } from 'meteor/check';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import filesize from 'filesize';
import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions';
import { settings } from '../../../settings/server';
import Uploads from '../../../models/server/models/Uploads';
import UserDataFiles from '../../../models/server/models/UserDataFiles';
import Avatars from '../../../models/server/models/Avatars';
import Users from '../../../models/server/models/Users';
import Rooms from '../../../models/server/models/Rooms';
import Settings from '../../../models/server/models/Settings';
import { mime } from '../../../utils/lib/mimeTypes';
import { roomTypes } from '../../../utils/server/lib/roomTypes';
import { hasPermission } from '../../../authorization/server/functions/hasPermission';
import { canAccessRoom } from '../../../authorization/server/functions/canAccessRoom';
import { fileUploadIsValidContentType } from '../../../utils/lib/fileUploadRestrictions';
import { isValidJWT, generateJWT } from '../../../utils/server/lib/JWTHelper';
import { Messages } from '../../../models/server';
import { AppEvents, Apps } from '../../../apps/server';
import { streamToBuffer } from './streamToBuffer';
import { SystemLogger } from '../../../../server/lib/logger/system';
const cookie = new Cookies();
let maxFileSize = 0;
settings.watch('FileUpload_MaxFileSize', function(value) {
try {
maxFileSize = parseInt(value);
} catch (e) {
maxFileSize = Settings.findOneById('FileUpload_MaxFileSize').packageValue;
}
});
export const FileUpload = {
handlers: {},
getPath(path = '') {
return `/file-upload/${ path }`;
},
configureUploadsStore(store, name, options) {
const type = name.split(':').pop();
const stores = UploadFS.getStores();
delete stores[name];
return new UploadFS.store[store](Object.assign({
name,
}, options, FileUpload[`default${ type }`]()));
},
validateFileUpload(fileData) {
const { file = fileData, content = Buffer.from([]) } = fileData;
if (!Match.test(file.rid, String)) {
return false;
}
// livechat users can upload files but they don't have an userId
const user = file.userId ? Meteor.users.findOne(file.userId) : null;
const room = Rooms.findOneById(file.rid);
const directMessageAllowed = settings.get('FileUpload_Enabled_Direct');
const fileUploadAllowed = settings.get('FileUpload_Enabled');
if (user?.type !== 'app' && canAccessRoom(room, user, file) !== true) {
return false;
}
const language = user ? user.language : 'en';
if (!fileUploadAllowed) {
const reason = TAPi18n.__('FileUpload_Disabled', language);
throw new Meteor.Error('error-file-upload-disabled', reason);
}
if (!directMessageAllowed && room.t === 'd') {
const reason = TAPi18n.__('File_not_allowed_direct_messages', 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 > maxFileSize) {
const reason = TAPi18n.__('File_exceeds_allowed_size_of_bytes', {
size: filesize(maxFileSize),
}, language);
throw new Meteor.Error('error-file-too-large', reason);
}
if (!fileUploadIsValidContentType(file.type)) {
const reason = TAPi18n.__('File_type_is_not_accepted', language);
throw new Meteor.Error('error-invalid-file-type', reason);
}
// App IPreFileUpload event hook
try {
Promise.await(Apps.triggerEvent(AppEvents.IPreFileUpload, { file, content }));
} catch (error) {
if (error instanceof AppsEngineException) {
throw new Meteor.Error('error-app-prevented', error.message);
}
throw error;
}
return true;
},
validateAvatarUpload({ file }) {
if (!Match.test(file.rid, String) && !Match.test(file.userId, String)) {
return false;
}
const user = file.uid ? Meteor.users.findOne(file.uid, { fields: { language: 1 } }) : null;
const language = user?.language || 'en';
// accept only images
if (!/^image\//.test(file.type)) {
const reason = TAPi18n.__('File_type_is_not_accepted', language);
throw new Meteor.Error('error-invalid-file-type', reason);
}
// -1 maxFileSize means there is no limit
if (maxFileSize > -1 && file.size > maxFileSize) {
const reason = TAPi18n.__('File_exceeds_allowed_size_of_bytes', {
size: filesize(maxFileSize),
}, language);
throw new Meteor.Error('error-file-too-large', reason);
}
return true;
},
defaultUploads() {
return {
collection: Uploads.model,
filter: new UploadFS.Filter({
onCheck: FileUpload.validateFileUpload,
}),
getPath(file) {
return `${ settings.get('uniqueID') }/uploads/${ file.rid }/${ file.userId }/${ file._id }`;
},
onValidate: FileUpload.uploadsOnValidate,
onRead(fileId, file, req, res) {
if (!FileUpload.requestCanAccessFiles(req)) {
res.writeHead(403);
return false;
}
res.setHeader('content-disposition', `attachment; filename="${ encodeURIComponent(file.name) }"`);
return true;
},
};
},
defaultAvatars() {
return {
collection: Avatars.model,
filter: new UploadFS.Filter({
onCheck: FileUpload.validateAvatarUpload,
}),
getPath(file) {
const avatarFile = file.rid ? `room-${ file.rid }` : file.userId;
return `${ settings.get('uniqueID') }/avatars/${ avatarFile }`;
},
onValidate: FileUpload.avatarsOnValidate,
onFinishUpload: FileUpload.avatarsOnFinishUpload,
};
},
defaultUserDataFiles() {
return {
collection: UserDataFiles.model,
getPath(file) {
return `${ settings.get('uniqueID') }/uploads/userData/${ file.userId }`;
},
onValidate: FileUpload.uploadsOnValidate,
onRead(fileId, file, req, res) {
if (!FileUpload.requestCanAccessFiles(req)) {
res.writeHead(403);
return false;
}
res.setHeader('content-disposition', `attachment; filename="${ encodeURIComponent(file.name) }"`);
return true;
},
};
},
avatarsOnValidate(file) {
if (settings.get('Accounts_AvatarResize') !== true) {
return;
}
if (file.rid) {
if (!hasPermission(Meteor.userId(), 'edit-room-avatar', file.rid)) {
throw new Meteor.Error('error-not-allowed', 'Change avatar is not allowed');
}
} else if (Meteor.userId() !== file.userId && !hasPermission(Meteor.userId(), 'edit-other-user-info')) {
throw new Meteor.Error('error-not-allowed', 'Change avatar is not allowed');
}
const tempFilePath = UploadFS.getTempFilePath(file._id);
const height = settings.get('Accounts_AvatarSize');
const width = height;
const future = new Future();
const s = sharp(tempFilePath);
s.rotate();
s.metadata(Meteor.bindEnvironment((err, metadata) => {
if (!metadata) {
metadata = {};
}
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 })
.then(Meteor.bindEnvironment(({ data, info }) => {
fs.writeFile(tempFilePath, data, Meteor.bindEnvironment((err) => {
if (err != null) {
SystemLogger.error(err);
}
this.getCollection().direct.update({ _id: file._id }, {
$set: {
size: info.size,
...['gif', 'svg'].includes(metadata.format) ? { type: 'image/png' } : {},
},
});
future.return();
}));
}));
}));
return future.wait();
},
resizeImagePreview(file) {
file = Uploads.findOneById(file._id);
file = FileUpload.addExtensionTo(file);
const image = 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;
},
createImageThumbnail(file) {
if (!settings.get('Message_Attachments_Thumbnails_Enabled')) {
return;
}
const width = settings.get('Message_Attachments_Thumbnails_Width');
const height = settings.get('Message_Attachments_Thumbnails_Height');
if (file.identify.size && file.identify.size.height < height && file.identify.size.width < width) {
return;
}
file = Uploads.findOneById(file._id);
file = FileUpload.addExtensionTo(file);
const store = FileUpload.getStore('Uploads');
const image = store._store.getReadStream(file._id, file);
const transformer = sharp()
.resize({ width, height, fit: 'inside' });
const result = transformer.toBuffer({ resolveWithObject: true }).then(({ data, info: { width, height } }) => ({ data, width, height }));
image.pipe(transformer);
return result;
},
uploadImageThumbnail(file, buffer, rid, userId) {
const store = FileUpload.getStore('Uploads');
const details = {
name: `thumb-${ file.name }`,
size: buffer.length,
type: file.type,
rid,
userId,
};
return store.insertSync(details, buffer);
},
uploadsOnValidate(file) {
if (!/^image\/((x-windows-)?bmp|p?jpeg|png|gif)$/.test(file.type)) {
return;
}
const tmpFile = UploadFS.getTempFilePath(file._id);
const fut = new Future();
const s = sharp(tmpFile);
s.metadata(Meteor.bindEnvironment((err, metadata) => {
if (err != null) {
SystemLogger.error(err);
return fut.return();
}
const rotated = typeof metadata.orientation !== 'undefined' && metadata.orientation !== 1;
const identify = {
format: metadata.format,
size: {
width: rotated ? metadata.height : metadata.width,
height: rotated ? metadata.width : metadata.height,
},
};
const reorientation = (cb) => {
if (!rotated || settings.get('FileUpload_RotateImages') !== true) {
return cb();
}
s.rotate()
.toFile(`${ tmpFile }.tmp`)
.then(Meteor.bindEnvironment(() => {
fs.unlink(tmpFile, Meteor.bindEnvironment(() => {
fs.rename(`${ tmpFile }.tmp`, tmpFile, Meteor.bindEnvironment(() => {
cb();
}));
}));
})).catch((err) => {
SystemLogger.error(err);
fut.return();
});
};
reorientation(() => {
const { size } = fs.lstatSync(tmpFile);
this.getCollection().direct.update({ _id: file._id }, {
$set: { size, identify },
});
fut.return();
});
}));
return fut.wait();
},
avatarRoomOnFinishUpload(file) {
if (!hasPermission(Meteor.userId(), 'edit-room-avatar', file.rid)) {
throw new Meteor.Error('error-not-allowed', 'Change avatar is not allowed');
}
},
avatarsOnFinishUpload(file) {
if (file.rid) {
return FileUpload.avatarRoomOnFinishUpload(file);
}
if (Meteor.userId() !== file.userId && !hasPermission(Meteor.userId(), 'edit-other-user-info')) {
throw new Meteor.Error('error-not-allowed', 'Change avatar is not allowed');
}
// update file record to match user's username
const user = Users.findOneById(file.userId);
const oldAvatar = Avatars.findOneByName(user.username);
if (oldAvatar) {
Avatars.deleteFile(oldAvatar._id);
}
Avatars.updateFileNameById(file._id, user.username);
// console.log('upload finished ->', file);
},
requestCanAccessFiles({ headers = {}, query = {} }) {
if (!settings.get('FileUpload_ProtectFiles')) {
return true;
}
let { rc_uid, rc_token, rc_rid, rc_room_type } = query;
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 isAuthorizedByCookies = rc_uid && rc_token && Users.findOneByIdAndLoginToken(rc_uid, rc_token);
const isAuthorizedByHeaders = headers['x-user-id'] && headers['x-auth-token'] && Users.findOneByIdAndLoginToken(headers['x-user-id'], headers['x-auth-token']);
const isAuthorizedByRoom = rc_room_type && roomTypes.getConfig(rc_room_type).canAccessUploadedFile({ rc_uid, rc_rid, rc_token });
const isAuthorizedByJWT = settings.get('FileUpload_Enable_json_web_token_for_files') && token && isValidJWT(token, settings.get('FileUpload_json_web_token_secret_for_files'));
return isAuthorizedByCookies || isAuthorizedByHeaders || isAuthorizedByRoom || isAuthorizedByJWT;
},
addExtensionTo(file) {
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) {
const storageType = settings.get('FileUpload_Storage_Type');
const handlerName = `${ storageType }:${ modelName }`;
return this.getStoreByName(handlerName);
},
getStoreByName(handlerName) {
if (this.handlers[handlerName] == null) {
SystemLogger.error(`Upload handler "${ handlerName }" does not exists`);
}
return this.handlers[handlerName];
},
get(file, req, res, next) {
const store = this.getStoreByName(file.store);
if (store && store.get) {
return store.get(file, req, res, next);
}
res.writeHead(404);
res.end();
},
getBuffer(file, cb) {
const store = this.getStoreByName(file.store);
if (!store || !store.get) { cb(new Error('Store is invalid'), null); }
const buffer = new streamBuffers.WritableStreamBuffer({
initialSize: file.size,
});
buffer.on('finish', () => {
cb(null, buffer.getContents());
});
store.copy(file, buffer);
},
getBufferSync: Meteor.wrapAsync((file, cb) => FileUpload.getBuffer(file, cb)),
copy(file, targetFile) {
const store = this.getStoreByName(file.store);
const out = fs.createWriteStream(targetFile);
file = FileUpload.addExtensionTo(file);
if (store.copy) {
store.copy(file, out);
return true;
}
return false;
},
redirectToFile(fileUrl, req, res) {
res.removeHeader('Content-Length');
res.removeHeader('Cache-Control');
res.setHeader('Location', fileUrl);
res.writeHead(302);
res.end();
},
proxyFile(fileName, fileUrl, forceDownload, request, req, res) {
res.setHeader('Content-Disposition', `${ forceDownload ? 'attachment' : 'inline' }; filename="${ encodeURI(fileName) }"`);
request.get(fileUrl, (fileRes) => fileRes.pipe(res));
},
generateJWTToFileUrls({ rid, userId, fileId }) {
if (!settings.get('FileUpload_ProtectFiles') || !settings.get('FileUpload_Enable_json_web_token_for_files')) {
return;
}
return generateJWT({
rid,
userId,
fileId,
}, settings.get('FileUpload_json_web_token_secret_for_files'));
},
removeFilesByRoomId(rid) {
if (typeof rid !== 'string' || rid.trim().length === 0) {
return;
}
Messages.find({
rid,
'file._id': {
$exists: true,
},
}, {
fields: {
'file._id': 1,
},
}).fetch().forEach((document) => FileUpload.getStore('Uploads').deleteById(document.file._id));
},
};
export class FileUploadClass {
constructor({ name, model, store, get, insert, getStore, copy }) {
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;
this.insertSync = Meteor.wrapAsync(this.insert, this);
}
getStore() {
return this._store;
}
get store() {
return this.getStore();
}
set store(store) {
this._store = store;
}
getModelFromName() {
const modelsAvailable = {
Avatars,
Uploads,
UserDataFiles,
};
const modelName = this.name.split(':')[1];
if (!modelsAvailable[modelName]) {
throw new Error('Invalid Model for FileUpload');
}
return modelsAvailable[modelName];
}
delete(fileId) {
if (this.store && this.store.delete) {
this.store.delete(fileId);
}
return this.model.deleteFile(fileId);
}
deleteById(fileId) {
const file = this.model.findOneById(fileId);
if (!file) {
return;
}
const store = FileUpload.getStoreByName(file.store);
return store.delete(file._id);
}
deleteByName(fileName) {
const file = this.model.findOneByName(fileName);
if (!file) {
return;
}
const store = FileUpload.getStoreByName(file.store);
return store.delete(file._id);
}
deleteByRoomId(rid) {
const file = this.model.findOneByRoomId(rid);
if (!file) {
return;
}
const store = FileUpload.getStoreByName(file.store);
return store.delete(file._id);
}
_doInsert(fileData, streamOrBuffer, cb) {
const fileId = this.store.create(fileData);
const token = this.store.createToken(fileId);
const tmpFile = UploadFS.getTempFilePath(fileId);
try {
if (streamOrBuffer instanceof stream) {
streamOrBuffer.pipe(fs.createWriteStream(tmpFile));
} else if (streamOrBuffer instanceof Buffer) {
fs.writeFileSync(tmpFile, streamOrBuffer);
} else {
throw new Error('Invalid file type');
}
const file = Meteor.call('ufsComplete', fileId, this.name, token);
if (cb) {
cb(null, file);
}
return file;
} catch (e) {
if (cb) {
cb(e);
} else {
throw e;
}
}
}
insert(fileData, streamOrBuffer, cb) {
if (streamOrBuffer instanceof stream) {
streamOrBuffer = Promise.await(streamToBuffer(streamOrBuffer));
}
// Check if the fileData matches store filter
const filter = this.store.getFilter();
if (filter && filter.check) {
filter.check({ file: fileData, content: streamOrBuffer });
}
return this._doInsert(fileData, streamOrBuffer, cb);
}
}