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.
613 lines
17 KiB
613 lines
17 KiB
/*
|
|
* The MIT License (MIT)
|
|
*
|
|
* Copyright (c) 2017 Karl STEIN
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
* of this software and associated documentation files (the "Software"), to deal
|
|
* in the Software without restriction, including without limitation the rights
|
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the Software is
|
|
* furnished to do so, subject to the following conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be included in all
|
|
* copies or substantial portions of the Software.
|
|
*
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
* SOFTWARE.
|
|
*
|
|
*/
|
|
import { check } from 'meteor/check';
|
|
import { Meteor } from 'meteor/meteor';
|
|
import { Mongo } from 'meteor/mongo';
|
|
import { _ } from 'meteor/underscore';
|
|
|
|
import { UploadFS } from './ufs';
|
|
import { Filter } from './ufs-filter';
|
|
import { StorePermissions } from './ufs-store-permissions';
|
|
import { Tokens } from './ufs-tokens';
|
|
|
|
/**
|
|
* File store
|
|
*/
|
|
export class Store {
|
|
constructor(options) {
|
|
const self = this;
|
|
|
|
// Default options
|
|
options = _.extend({
|
|
collection: null,
|
|
filter: null,
|
|
name: null,
|
|
onCopyError: this.onCopyError,
|
|
onFinishUpload: this.onFinishUpload,
|
|
onRead: this.onRead,
|
|
onReadError: this.onReadError,
|
|
onValidate: this.onValidate,
|
|
onWriteError: this.onWriteError,
|
|
permissions: null,
|
|
transformRead: null,
|
|
transformWrite: null,
|
|
}, options);
|
|
|
|
// Check options
|
|
if (!(options.collection instanceof Mongo.Collection)) {
|
|
throw new TypeError('Store: collection is not a Mongo.Collection');
|
|
}
|
|
if (options.filter && !(options.filter instanceof Filter)) {
|
|
throw new TypeError('Store: filter is not a UploadFS.Filter');
|
|
}
|
|
if (typeof options.name !== 'string') {
|
|
throw new TypeError('Store: name is not a string');
|
|
}
|
|
if (UploadFS.getStore(options.name)) {
|
|
throw new TypeError('Store: name already exists');
|
|
}
|
|
if (options.onCopyError && typeof options.onCopyError !== 'function') {
|
|
throw new TypeError('Store: onCopyError is not a function');
|
|
}
|
|
if (options.onFinishUpload && typeof options.onFinishUpload !== 'function') {
|
|
throw new TypeError('Store: onFinishUpload is not a function');
|
|
}
|
|
if (options.onRead && typeof options.onRead !== 'function') {
|
|
throw new TypeError('Store: onRead is not a function');
|
|
}
|
|
if (options.onReadError && typeof options.onReadError !== 'function') {
|
|
throw new TypeError('Store: onReadError is not a function');
|
|
}
|
|
if (options.onWriteError && typeof options.onWriteError !== 'function') {
|
|
throw new TypeError('Store: onWriteError is not a function');
|
|
}
|
|
if (options.permissions && !(options.permissions instanceof StorePermissions)) {
|
|
throw new TypeError('Store: permissions is not a UploadFS.StorePermissions');
|
|
}
|
|
if (options.transformRead && typeof options.transformRead !== 'function') {
|
|
throw new TypeError('Store: transformRead is not a function');
|
|
}
|
|
if (options.transformWrite && typeof options.transformWrite !== 'function') {
|
|
throw new TypeError('Store: transformWrite is not a function');
|
|
}
|
|
if (options.onValidate && typeof options.onValidate !== 'function') {
|
|
throw new TypeError('Store: onValidate is not a function');
|
|
}
|
|
|
|
// Public attributes
|
|
self.options = options;
|
|
self.permissions = options.permissions;
|
|
[
|
|
'onCopyError',
|
|
'onFinishUpload',
|
|
'onRead',
|
|
'onReadError',
|
|
'onWriteError',
|
|
'onValidate',
|
|
].forEach((method) => {
|
|
if (typeof options[method] === 'function') {
|
|
self[method] = options[method];
|
|
}
|
|
});
|
|
|
|
// Add the store to the list
|
|
UploadFS.addStore(self);
|
|
|
|
// Set default permissions
|
|
if (!(self.permissions instanceof StorePermissions)) {
|
|
// Uses custom default permissions or UFS default permissions
|
|
if (UploadFS.config.defaultStorePermissions instanceof StorePermissions) {
|
|
self.permissions = UploadFS.config.defaultStorePermissions;
|
|
} else {
|
|
self.permissions = new StorePermissions();
|
|
console.warn(`ufs: permissions are not defined for store "${ options.name }"`);
|
|
}
|
|
}
|
|
|
|
if (Meteor.isServer) {
|
|
/**
|
|
* Checks token validity
|
|
* @param token
|
|
* @param fileId
|
|
* @returns {boolean}
|
|
*/
|
|
self.checkToken = function(token, fileId) {
|
|
check(token, String);
|
|
check(fileId, String);
|
|
return Tokens.find({ value: token, fileId }).count() === 1;
|
|
};
|
|
|
|
/**
|
|
* Copies the file to a store
|
|
* @param fileId
|
|
* @param store
|
|
* @param callback
|
|
*/
|
|
self.copy = function(fileId, store, callback) {
|
|
check(fileId, String);
|
|
|
|
if (!(store instanceof Store)) {
|
|
throw new TypeError('store is not an instance of UploadFS.Store');
|
|
}
|
|
// Get original file
|
|
const file = self.getCollection().findOne({ _id: fileId });
|
|
if (!file) {
|
|
throw new Meteor.Error('file-not-found', 'File not found');
|
|
}
|
|
// Silently ignore the file if it does not match filter
|
|
const filter = store.getFilter();
|
|
if (filter instanceof Filter && !filter.isValid(file)) {
|
|
return;
|
|
}
|
|
|
|
// Prepare copy
|
|
const { _id, url, ...copy } = file;
|
|
copy.originalStore = self.getName();
|
|
copy.originalId = fileId;
|
|
|
|
// Create the copy
|
|
const copyId = store.create(copy);
|
|
|
|
// Get original stream
|
|
const rs = self.getReadStream(fileId, file);
|
|
|
|
// Catch errors to avoid app crashing
|
|
rs.on('error', Meteor.bindEnvironment(function(err) {
|
|
callback.call(self, err, null);
|
|
}));
|
|
|
|
// Copy file data
|
|
store.write(rs, copyId, Meteor.bindEnvironment(function(err) {
|
|
if (err) {
|
|
self.getCollection().remove({ _id: copyId });
|
|
self.onCopyError.call(self, err, fileId, file);
|
|
}
|
|
if (typeof callback === 'function') {
|
|
callback.call(self, err, copyId, copy, store);
|
|
}
|
|
}));
|
|
};
|
|
|
|
/**
|
|
* Creates the file in the collection
|
|
* @param file
|
|
* @param callback
|
|
* @return {string}
|
|
*/
|
|
self.create = function(file, callback) {
|
|
check(file, Object);
|
|
file.store = self.options.name; // assign store to file
|
|
return self.getCollection().insert(file, callback);
|
|
};
|
|
|
|
/**
|
|
* Creates a token for the file (only needed for client side upload)
|
|
* @param fileId
|
|
* @returns {*}
|
|
*/
|
|
self.createToken = function(fileId) {
|
|
const token = self.generateToken();
|
|
|
|
// Check if token exists
|
|
if (Tokens.find({ fileId }).count()) {
|
|
Tokens.update({ fileId }, {
|
|
$set: {
|
|
createdAt: new Date(),
|
|
value: token,
|
|
},
|
|
});
|
|
} else {
|
|
Tokens.insert({
|
|
createdAt: new Date(),
|
|
fileId,
|
|
value: token,
|
|
});
|
|
}
|
|
return token;
|
|
};
|
|
|
|
/**
|
|
* Writes the file to the store
|
|
* @param rs
|
|
* @param fileId
|
|
* @param callback
|
|
*/
|
|
self.write = function(rs, fileId, callback) {
|
|
const file = self.getCollection().findOne({ _id: fileId });
|
|
|
|
const errorHandler = Meteor.bindEnvironment(function(err) {
|
|
self.onWriteError.call(self, err, fileId, file);
|
|
callback.call(self, err);
|
|
});
|
|
|
|
const finishHandler = Meteor.bindEnvironment(function() {
|
|
let size = 0;
|
|
const readStream = self.getReadStream(fileId, file);
|
|
|
|
readStream.on('error', Meteor.bindEnvironment(function(error) {
|
|
callback.call(self, error, null);
|
|
}));
|
|
readStream.on('data', Meteor.bindEnvironment(function(data) {
|
|
size += data.length;
|
|
}));
|
|
readStream.on('end', Meteor.bindEnvironment(function() {
|
|
if (file.complete) {
|
|
return;
|
|
}
|
|
// Set file attribute
|
|
file.complete = true;
|
|
file.etag = UploadFS.generateEtag();
|
|
file.path = self.getFileRelativeURL(fileId);
|
|
file.progress = 1;
|
|
file.size = size;
|
|
file.token = self.generateToken();
|
|
file.uploading = false;
|
|
file.uploadedAt = new Date();
|
|
file.url = self.getFileURL(fileId);
|
|
|
|
// Execute callback
|
|
if (typeof self.onFinishUpload === 'function') {
|
|
self.onFinishUpload.call(self, file);
|
|
}
|
|
|
|
// Sets the file URL when file transfer is complete,
|
|
// this way, the image will loads entirely.
|
|
self.getCollection().direct.update({ _id: fileId }, {
|
|
$set: {
|
|
complete: file.complete,
|
|
etag: file.etag,
|
|
path: file.path,
|
|
progress: file.progress,
|
|
size: file.size,
|
|
token: file.token,
|
|
uploading: file.uploading,
|
|
uploadedAt: file.uploadedAt,
|
|
url: file.url,
|
|
},
|
|
});
|
|
|
|
// Return file info
|
|
callback.call(self, null, file);
|
|
|
|
// Simulate write speed
|
|
if (UploadFS.config.simulateWriteDelay) {
|
|
Meteor._sleepForMs(UploadFS.config.simulateWriteDelay);
|
|
}
|
|
|
|
// Copy file to other stores
|
|
if (self.options.copyTo instanceof Array) {
|
|
for (let i = 0; i < self.options.copyTo.length; i += 1) {
|
|
const store = self.options.copyTo[i];
|
|
|
|
if (!store.getFilter() || store.getFilter().isValid(file)) {
|
|
self.copy(fileId, store);
|
|
}
|
|
}
|
|
}
|
|
}));
|
|
});
|
|
|
|
const ws = self.getWriteStream(fileId, file);
|
|
ws.on('error', errorHandler);
|
|
ws.once('finish', finishHandler);
|
|
|
|
// Execute transformation
|
|
self.transformWrite(rs, ws, fileId, file);
|
|
};
|
|
}
|
|
|
|
if (Meteor.isServer) {
|
|
// eslint-disable-next-line no-undef
|
|
const fs = Npm.require('fs');
|
|
const collection = self.getCollection();
|
|
|
|
// Code executed after removing file
|
|
collection.after.remove(function(userId, file) {
|
|
// Remove associated tokens
|
|
Tokens.remove({ fileId: file._id });
|
|
|
|
if (self.options.copyTo instanceof Array) {
|
|
for (let i = 0; i < self.options.copyTo.length; i += 1) {
|
|
// Remove copies in stores
|
|
self.options.copyTo[i].getCollection().remove({ originalId: file._id });
|
|
}
|
|
}
|
|
});
|
|
|
|
// Code executed before inserting file
|
|
collection.before.insert(function(userId, file) {
|
|
if (!self.permissions.checkInsert(userId, file)) {
|
|
throw new Meteor.Error('forbidden', 'Forbidden');
|
|
}
|
|
});
|
|
|
|
// Code executed before updating file
|
|
collection.before.update(function(userId, file, fields, modifiers) {
|
|
if (!self.permissions.checkUpdate(userId, file, fields, modifiers)) {
|
|
throw new Meteor.Error('forbidden', 'Forbidden');
|
|
}
|
|
});
|
|
|
|
// Code executed before removing file
|
|
collection.before.remove(function(userId, file) {
|
|
if (!self.permissions.checkRemove(userId, file)) {
|
|
throw new Meteor.Error('forbidden', 'Forbidden');
|
|
}
|
|
|
|
// Delete the physical file in the store
|
|
self.delete(file._id);
|
|
|
|
const tmpFile = UploadFS.getTempFilePath(file._id);
|
|
|
|
// Delete the temp file
|
|
fs.stat(tmpFile, function(err) {
|
|
!err && fs.unlink(tmpFile, function(err) {
|
|
err && console.error(`ufs: cannot delete temp file at ${ tmpFile } (${ err.message })`);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deletes a file async
|
|
* @param fileId
|
|
* @param callback
|
|
*/
|
|
// eslint-disable-next-line no-unused-vars
|
|
delete(fileId, callback) {
|
|
throw new Error('delete is not implemented');
|
|
}
|
|
|
|
/**
|
|
* Generates a random token
|
|
* @param pattern
|
|
* @return {string}
|
|
*/
|
|
generateToken(pattern) {
|
|
return (pattern || 'xyxyxyxyxy').replace(/[xy]/g, (c) => {
|
|
// eslint-disable-next-line no-mixed-operators
|
|
const r = Math.random() * 16 | 0; const v = c === 'x' ? r : r & 0x3 | 0x8;
|
|
const s = v.toString(16);
|
|
return Math.round(Math.random()) ? s.toUpperCase() : s;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns the collection
|
|
* @return {Mongo.Collection}
|
|
*/
|
|
getCollection() {
|
|
return this.options.collection;
|
|
}
|
|
|
|
/**
|
|
* Returns the file URL
|
|
* @param fileId
|
|
* @return {string|null}
|
|
*/
|
|
getFileRelativeURL(fileId) {
|
|
const file = this.getCollection().findOne(fileId, { fields: { name: 1 } });
|
|
return file ? this.getRelativeURL(`${ fileId }/${ file.name }`) : null;
|
|
}
|
|
|
|
/**
|
|
* Returns the file URL
|
|
* @param fileId
|
|
* @return {string|null}
|
|
*/
|
|
getFileURL(fileId) {
|
|
const file = this.getCollection().findOne(fileId, { fields: { name: 1 } });
|
|
return file ? this.getURL(`${ fileId }/${ file.name }`) : null;
|
|
}
|
|
|
|
/**
|
|
* Returns the file filter
|
|
* @return {UploadFS.Filter}
|
|
*/
|
|
getFilter() {
|
|
return this.options.filter;
|
|
}
|
|
|
|
/**
|
|
* Returns the store name
|
|
* @return {string}
|
|
*/
|
|
getName() {
|
|
return this.options.name;
|
|
}
|
|
|
|
/**
|
|
* Returns the file read stream
|
|
* @param fileId
|
|
* @param file
|
|
*/
|
|
// eslint-disable-next-line no-unused-vars
|
|
getReadStream(fileId, file) {
|
|
throw new Error('Store.getReadStream is not implemented');
|
|
}
|
|
|
|
/**
|
|
* Returns the store relative URL
|
|
* @param path
|
|
* @return {string}
|
|
*/
|
|
getRelativeURL(path) {
|
|
const rootUrl = Meteor.absoluteUrl().replace(/\/+$/, '');
|
|
const rootPath = rootUrl.replace(/^[a-z]+:\/\/[^/]+\/*/gi, '');
|
|
const storeName = this.getName();
|
|
path = String(path).replace(/\/$/, '').trim();
|
|
return encodeURI(`${ rootPath }/${ UploadFS.config.storesPath }/${ storeName }/${ path }`);
|
|
}
|
|
|
|
/**
|
|
* Returns the store absolute URL
|
|
* @param path
|
|
* @return {string}
|
|
*/
|
|
getURL(path) {
|
|
const rootUrl = Meteor.absoluteUrl({ secure: UploadFS.config.https }).replace(/\/+$/, '');
|
|
const storeName = this.getName();
|
|
path = String(path).replace(/\/$/, '').trim();
|
|
return encodeURI(`${ rootUrl }/${ UploadFS.config.storesPath }/${ storeName }/${ path }`);
|
|
}
|
|
|
|
/**
|
|
* Returns the file write stream
|
|
* @param fileId
|
|
* @param file
|
|
*/
|
|
// eslint-disable-next-line no-unused-vars
|
|
getWriteStream(fileId, file) {
|
|
throw new Error('getWriteStream is not implemented');
|
|
}
|
|
|
|
/**
|
|
* Completes the file upload
|
|
* @param url
|
|
* @param file
|
|
* @param callback
|
|
*/
|
|
importFromURL(url, file, callback) {
|
|
Meteor.call('ufsImportURL', url, file, this.getName(), callback);
|
|
}
|
|
|
|
/**
|
|
* Called when a copy error happened
|
|
* @param err
|
|
* @param fileId
|
|
* @param file
|
|
*/
|
|
// eslint-disable-next-line no-unused-vars
|
|
onCopyError(err, fileId, file) {
|
|
console.error(`ufs: cannot copy file "${ fileId }" (${ err.message })`, err);
|
|
}
|
|
|
|
/**
|
|
* Called when a file has been uploaded
|
|
* @param file
|
|
*/
|
|
// eslint-disable-next-line no-unused-vars
|
|
onFinishUpload(file) {
|
|
}
|
|
|
|
/**
|
|
* Called when a file is read from the store
|
|
* @param fileId
|
|
* @param file
|
|
* @param request
|
|
* @param response
|
|
* @return boolean
|
|
*/
|
|
// eslint-disable-next-line no-unused-vars
|
|
onRead(fileId, file, request, response) {
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Called when a read error happened
|
|
* @param err
|
|
* @param fileId
|
|
* @param file
|
|
* @return boolean
|
|
*/
|
|
// eslint-disable-next-line no-unused-vars
|
|
onReadError(err, fileId, file) {
|
|
console.error(`ufs: cannot read file "${ fileId }" (${ err.message })`, err);
|
|
}
|
|
|
|
/**
|
|
* Called when file is being validated
|
|
* @param file
|
|
*/
|
|
// eslint-disable-next-line no-unused-vars
|
|
onValidate(file) {
|
|
}
|
|
|
|
/**
|
|
* Called when a write error happened
|
|
* @param err
|
|
* @param fileId
|
|
* @param file
|
|
* @return boolean
|
|
*/
|
|
// eslint-disable-next-line no-unused-vars
|
|
onWriteError(err, fileId, file) {
|
|
console.error(`ufs: cannot write file "${ fileId }" (${ err.message })`, err);
|
|
}
|
|
|
|
/**
|
|
* Sets the store permissions
|
|
* @param permissions
|
|
*/
|
|
setPermissions(permissions) {
|
|
if (!(permissions instanceof StorePermissions)) {
|
|
throw new TypeError('Permissions is not an instance of UploadFS.StorePermissions');
|
|
}
|
|
this.permissions = permissions;
|
|
}
|
|
|
|
/**
|
|
* Transforms the file on reading
|
|
* @param readStream
|
|
* @param writeStream
|
|
* @param fileId
|
|
* @param file
|
|
* @param request
|
|
* @param headers
|
|
*/
|
|
transformRead(readStream, writeStream, fileId, file, request, headers) {
|
|
if (typeof this.options.transformRead === 'function') {
|
|
this.options.transformRead.call(this, readStream, writeStream, fileId, file, request, headers);
|
|
} else {
|
|
readStream.pipe(writeStream);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Transforms the file on writing
|
|
* @param readStream
|
|
* @param writeStream
|
|
* @param fileId
|
|
* @param file
|
|
*/
|
|
transformWrite(readStream, writeStream, fileId, file) {
|
|
if (typeof this.options.transformWrite === 'function') {
|
|
this.options.transformWrite.call(this, readStream, writeStream, fileId, file);
|
|
} else {
|
|
readStream.pipe(writeStream);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates the file
|
|
* @param file
|
|
*/
|
|
validate(file) {
|
|
if (typeof this.onValidate === 'function') {
|
|
this.onValidate(file);
|
|
}
|
|
}
|
|
}
|
|
|