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 { 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'; const cookie = new Cookies(); let maxFileSize = 0; settings.get('FileUpload_MaxFileSize', function(key, 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(file) { 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 directMessageAllow = settings.get('FileUpload_Enabled_Direct'); const fileUploadAllowed = settings.get('FileUpload_Enabled'); if (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 (!directMessageAllow && 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); } 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.validateFileUpload // }), getPath(file) { return `${ settings.get('uniqueID') }/avatars/${ file.userId }`; }, 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 (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 future = new Future(); const s = sharp(tempFilePath); s.rotate(); // Get metadata to resize the image the first time to keep "inside" the dimensions // then resize again to create the canvas around s.metadata(Meteor.bindEnvironment((err, metadata) => { if (!metadata) { metadata = {}; } s.flatten({ background: '#FFFFFF' }) .jpeg() .resize({ width: Math.min(height || 0, metadata.width || Infinity), height: Math.min(height || 0, metadata.height || Infinity), fit: sharp.fit.cover, }) .pipe(sharp() .resize({ height, width: height, fit: sharp.fit.contain, background: '#FFFFFF', }) ) // Use buffer to get the result in memory then replace the existing file // There is no option to override a file using this library .toBuffer() .then(Meteor.bindEnvironment((outputBuffer) => { fs.writeFile(tempFilePath, outputBuffer, Meteor.bindEnvironment((err) => { if (err != null) { console.error(err); } const { size } = fs.lstatSync(tempFilePath); this.getCollection().direct.update({ _id: file._id }, { $set: { size } }); 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; }, uploadsOnValidate(file) { if (!/^image\/((x-windows-)?bmp|p?jpeg|png)$/.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) { console.error(err); return fut.return(); } const identify = { format: metadata.format, size: { width: metadata.width, height: metadata.height, }, }; const reorientation = (cb) => { if (!metadata.orientation) { 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) => { console.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(); }, avatarsOnFinishUpload(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; 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 }); return isAuthorizedByCookies || isAuthorizedByHeaders || isAuthorizedByRoom; }, 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) { console.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); }, 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; }, }; 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; } 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); } insert(fileData, streamOrBuffer, cb) { fileData.size = parseInt(fileData.size) || 0; // Check if the fileData matches store filter const filter = this.store.getFilter(); if (filter && filter.check) { filter.check(fileData); } 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; } } } }