apostrophe
Version:
Apostrophe is a user-friendly content management system. You'll need more than this core module. See apostrophenow.org to get started.
1,232 lines (1,149 loc) • 42.2 kB
JavaScript
var hash_file = require('hash_file');
var _ = require('lodash');
var fs = require('fs');
var async = require('async');
var path = require('path');
var extend = require('extend');
var crypto = require('crypto');
var joinr = require('joinr');
/**
* files
* @augments Augments the apos object with methods, routes and
* properties supporting the management of media files (images, PDFs, etc.)
* uploaded by users.
*/
module.exports = {
/**
* Augment apos object with resources necessary prior to init() call
* @param {Object} self The apos object
*/
construct: function(self) {
// For convenience when configuring uploadfs. We recommend always configuring
// these sizes and adding more if you wish
self.defaultImageSizes = [
{
name: 'full',
width: 1140,
height: 1140
},
{
name: 'two-thirds',
width: 760,
height: 760
},
{
name: 'one-half',
width: 570,
height: 700
},
{
name: 'one-third',
width: 380,
height: 700
},
// Handy for thumbnailing
{
name: 'one-sixth',
width: 190,
height: 350
}
];
// mediaLibrary.js would have to be patched to support changing this. -Tom
self.trashImageSizes = [ 'one-sixth' ];
// Default file type groupings
self.fileGroups = [
{
name: 'images',
label: 'Images',
extensions: [ 'gif', 'jpg', 'png' ],
extensionMaps: {
jpeg: 'jpg'
},
// uploadfs should treat this as an image and create scaled versions
image: true
},
{
name: 'office',
label: 'Office',
extensions: [ 'txt', 'rtf', 'pdf', 'xls', 'ppt', 'doc', 'pptx', 'sldx', 'ppsx', 'potx', 'xlsx', 'xltx', 'docx', 'dotx' ],
extensionMaps: {},
// uploadfs should just accept this file as-is
image: false
},
];
},
/**
* Augment apos object with resources that cannot be added until the init call
* @param {Object} self The apos object
*/
init: function(self) {
// Make the browser aware of any global options for files.
// If we add options that are not suitable to pass to the
// browser we can cherrypick here instead. -Tom
self.pushGlobalData({
files: self.options.files || {}
});
// Find an image referenced within an area, such as an image in a slideshow widget.
// Returns the first image matching the criteria. Only GIF, JPEG and PNG images
// will ever be returned.
//
// EASY SYNTAX:
//
// apos.areaImage(page, 'body')
//
// You may also add options, such as "extension" to force the results to
// include JPEGs only:
//
// apos.areaImage(page, 'body', { extension: 'jpg' })
//
// (Note Apostrophe always uses .jpg for JPEGs.)
//
// CLASSIC SYNTAX (this is the hard way):
//
// apos.areaImage({ area: page.body })
//
// OPTIONS:
//
// You may specify `extension` or `extensions` (an array of extensions)
// to filter the results.
self.areaImage = function(_options /* or page, name, options */) {
var options = {};
if (arguments.length > 1) {
options.area = arguments[0] && arguments[0][arguments[1]];
_.merge(options, arguments[2]);
} else {
_.merge(options, _options);
}
options.group = 'images';
return self.areaFile(options);
};
// Find images referenced within an area, such as images in a slideshow widget.
// Returns all the files matching the criteria unless the "limit" option is used.
//
// EASY SYNTAX:
//
// apos.areaImages(page, 'body')
//
// Now you can loop over them with "for".
//
// You may also add options:
//
// apos.areaImages(page, 'body', { extension: 'jpg', limit: 2 })
//
// Note that Apostrophe always uses three-letter lowercase extensions.
//
// CLASSIC SYNTAX:
//
// apos.areaImage({ area: page.body })
//
// OPTIONS:
//
// You may specify `extension`, or `extensions` (an array of extensions).
//
// The `limit` option limits the number of results returned. Note that
// `areaImage` is more convenient than `apos.areaImages` if limit is 1.
//
// See also apos.areaFiles.
self.areaImages = function(_options /* or page, name, options */) {
var options = {};
if (arguments.length > 1) {
options.area = arguments[0] && arguments[0][arguments[1]];
_.merge(options, arguments[2]);
} else {
_.merge(options, _options);
}
options.group = 'images';
return self.areaFiles(options);
};
// Find a file referenced within an area, such as an image in a slideshow widget,
// or a PDF in a file widget.
//
// Returns the first file matching the criteria.
//
// EASY SYNTAX:
//
// apos.areaFile(page, 'body')
//
// You may also add options:
//
// apos.areaFile(page, 'body', { extension: 'jpg' })
//
// CLASSIC SYNTAX:
//
// apos.areaFile({ area: page.body })
//
// OPTIONS:
//
// You may specify `extension`, `extensions` (an array of extensions)
// or `group` to filter the results. By default the `images` and `office` groups
// are available.
//
// If you are using `group: "images"` consider calling apos.areaImage instead.
// This is convenient and protects you from accidentally getting a PDF file.
self.areaFile = function(_options /* or page, name, options */) {
var options = {};
if (arguments.length > 1) {
options.area = arguments[0] && arguments[0][arguments[1]];
_.merge(options, arguments[2]);
} else {
_.merge(options, _options);
}
options.limit = 1;
var files = self.areaFiles(options);
return files[0];
};
// Find files referenced within an area, such as an image in a slideshow widget.
// Returns all the files matching the criteria unless the "limit" option is used.
//
// EASY SYNTAX:
//
// apos.areaFiles(page, 'body')
//
// You may also add options:
//
// apos.areaFiles(page, 'body', { extension: 'jpg', limit: 2 })
//
// CLASSIC SYNTAX:
//
// apos.areaFile({ area: page.body })
//
// OPTIONS:
//
// You may specify `extension`, `extensions` (an array of extensions)
// or `group` to filter the results. By default the `images` and `office` groups
// are available.
//
// The `limit` option limits the number of results returned. Note that
// `areaFile` is more convenient than `apos.areaFiles`.
self.areaFiles = function(_options /* or page, name, options */) {
var options = {};
if (arguments.length > 1) {
options.area = arguments[0] && arguments[0][arguments[1]];
_.merge(options, arguments[2]);
} else {
_.merge(options, _options);
}
function testFile(file) {
if (file.extension === undefined) {
// Probably not a file
return false;
}
if (options.extension) {
if (file.extension !== options.extension) {
return false;
}
}
if (options.group) {
if (file.group !== options.group) {
return false;
}
}
if (options.extensions) {
if (!_.contains(options.extensions, file.extension)) {
return false;
}
}
return true;
}
if (!options) {
options = {};
}
var area = options.area;
var winningFiles = [];
if (!(area && area.items)) {
return [];
}
var i, j;
for (i = 0; (i < area.items.length); i++) {
var item = area.items[i];
// The slideshow, files and similar widgets use an 'items' array
// to store files. Let's look there, and also allow for '_items' to
// support future widgets that pull in files dynamically. However
// we also must make sure the items are actually files by making
// sure they have an `extension` property. (TODO: this is a hack,
// think about having widgets register to participate in this.)
if (!item._items) {
continue;
}
for (j = 0; (j < item._items.length); j++) {
if ((options.limit !== undefined) && (winningFiles.length >= options.limit)) {
return winningFiles;
}
var file = item._items[j];
var good = testFile(file);
if (good) {
winningFiles.push(file);
} else {
}
}
}
return winningFiles;
};
// bc wrapper, see areaFile. Use apply so that if areaFiles is
// overridden we call the override
self.areaFindFile = function() {
return self.areaFile.apply(self, arguments);
};
// Upload files. Expects an HTTP file upload from an
// `<input type="file" multiple />` element, accessed
// via Express as `req.files.files`. Also accepts a
// single-file upload under that same name. Files are
// copied into UploadFS and metadata is stored in the
// apos.files collection. Images are automatically
// scaled to all configured sizes at upload time.
// This route responds with a JSON object; if the
// `status` property is `ok` you will find an array
// of metadata objects about the uploaded files
// in the `files` property. At this point you may assume
// the files have been copied into uploadfs and all scaled
// versions have been generated if appropriate. The user
// must have the `edit-file` permission, which is
// normally granted to all users in groups with the
// `edit` or `admin` permission.
// If you are allowing for public image uploading into
// the media library (perhaps using apostrophe-moderator),
// IE9 and below do not react properly to the json content
// type. Post images to '/apos/upload-files?html=1' and
// the server will respond with text/html instead.
self.app.post('/apos/upload-files', function(req, res) {
// Must use text/plain for file upload responses in IE <= 9,
// doesn't hurt in other browsers. -Tom
res.header("Content-Type", "text/plain");
return self.acceptFiles(req, req.files.files, function(err, files) {
if (err) {
console.error(err);
return res.send({ files: [], status: 'err' });
}
if(req.query.html) {
res.setHeader('Content-Type', 'text/html');
}
return res.send({ files: files, status: 'ok' });
});
});
// Accept one or more files, as submitted by an HTTP file upload.
// req is checked for permissions. The callback receives an error if any
// followed by an array of new file objects as stored in aposFiles.
//
// "files" should be an array of objects with "name" and "path"
// properties, or a single such object. "name" must be the name the
// user claims for the file, while "path" must be the actual full path
// to the file on disk and need not have any file extension necessarily.
//
// (Note that when using Express to handle file uploads,
// req.files['yourfieldname'] will be such an array or object.)
//
// `options` may be omitted entirely. If `options.deDuplicate`
// is true, attempts to upload a file that exactly matches
// the contents of an existing file will be ignored, and
// the existing file's information is silently substituted
// in the response.
self.acceptFiles = function(req, files, options, callback) {
if (arguments.length === 3) {
callback = options;
options = {};
}
var newFiles = files;
if (!(newFiles instanceof Array)) {
newFiles = [ newFiles ];
}
var infos = [];
return async.map(newFiles, function(file, callback) {
var extension = path.extname(file.name);
if (extension && extension.length) {
extension = extension.substr(1);
}
extension = extension.toLowerCase();
// Do we accept this file extension?
var accepted = [];
var group = _.find(self.fileGroups, function(group) {
accepted.push(group.extensions);
var candidate = group.extensionMaps[extension] || extension;
if (_.contains(group.extensions, candidate)) {
return true;
}
});
if (!group) {
return callback("File extension not accepted. Acceptable extensions: " + accepted.join(", "));
}
var image = group.image;
var info = {
_id: self.generateId(),
length: file.length,
group: group.name,
createdAt: new Date(),
name: self.slugify(path.basename(file.name, path.extname(file.name))),
title: self.sortify(path.basename(file.name, path.extname(file.name))),
extension: extension
};
function permissions(callback) {
return callback(self.permissions.can(req, 'edit-file') ? null : 'forbidden');
}
function md5(callback) {
return self.md5File(file.path, function(err, md5) {
if (err) {
return callback(err);
}
info.md5 = md5;
return callback(null);
});
}
function reuseOrUpload(callback) {
if (!options.deDuplicate) {
return async.series([upload, db], callback);
}
return self.files.findOne({ md5: info.md5 }, function(err, existing) {
if (err) {
return callback(err);
}
if (existing) {
infos.push(existing);
return callback(null);
} else {
async.series([upload, db], callback);
}
});
}
function upload(callback) {
if (image) {
// For images we correct automatically for common file extension mistakes
return self.uploadfs.copyImageIn(file.path, '/files/' + info._id + '-' + info.name, function(err, result) {
if (err) {
return callback(err);
}
info.extension = result.extension;
info.width = result.width;
info.height = result.height;
info._owner = req.user;
info.searchText = self.fileSearchText(info);
if (info.width > info.height) {
info.landscape = true;
} else {
info.portrait = true;
}
return callback(null);
});
} else {
// For non-image files we have to trust the file extension
// (but we only serve it as that content type, so this should
// be reasonably safe)
return self.uploadfs.copyIn(file.path, '/files/' + info._id + '-' + info.name + '.' + info.extension, callback);
}
}
function db(callback) {
info.ownerId = self.permissions.getEffectiveUserId(req);
self.files.insert(info, { safe: true }, function(err, docs) {
if (!err) {
infos.push(docs[0]);
}
return callback(err);
});
}
async.series([ permissions, md5, reuseOrUpload ], callback);
}, function(err) {
if (err) {
return callback(err);
} else {
return callback(err, infos);
}
});
};
// Replace one file. A single file upload is expected in the
// input element with the name `files`. The id of the
// existing file object must be in the `id` query parameter.
self.app.post('/apos/replace-file', function(req, res) {
// Must use text/plain for file upload responses in IE <= 9,
// doesn't hurt in other browsers. -Tom
res.header("Content-Type", "text/plain");
// TODO: reduce redundancy with /apos/upload-files
var id = req.query.id;
return self.files.findOne({ _id: id }, function(err, file) {
if (err || (!file)) {
return self.fail(req, res);
}
if (!self.permissions.can(req, 'edit-file', file)) {
return self.fail(req, res);
}
var newFiles = req.files.files;
if (!(newFiles instanceof Array)) {
newFiles = [ newFiles ];
}
if (!newFiles.length) {
return self.fail(req, res);
}
// The last file is the one we're interested in if they
// somehow send more than one
var upload = newFiles.pop();
var extension = path.extname(upload.name);
if (extension && extension.length) {
extension = extension.substr(1);
}
extension = extension.toLowerCase();
// Do we accept this file extension?
var accepted = [];
var group = _.find(self.fileGroups, function(group) {
accepted.push(group.extensions);
var candidate = group.extensionMaps[extension] || extension;
if (_.contains(group.extensions, candidate)) {
return true;
}
});
if (!group) {
res.statusCode = 400;
return res.send("File extension not accepted. Acceptable extensions: " + accepted.join(", "));
}
// Don't mess with previously edited metadata, but do allow
// the actual filename, extension, etc. to be updated
var image = group.image;
extend(file, {
length: file.length,
group: group.name,
createdAt: new Date(),
name: self.slugify(path.basename(upload.name, path.extname(upload.name))),
extension: extension
});
function md5(callback) {
return self.md5File(upload.path, function(err, md5) {
if (err) {
return callback(err);
}
file.md5 = md5;
return callback(null);
});
}
// If a duplicate file is uploaded, quietly reuse the old one to
// avoid filling the hard drive
//
// Quietly removed for now due to issues with the occasional need
// for two copies to allow two titles. Now that we have a good
// media library automatic duplicate prevention is less urgent.
//
// function reuseOrUpload(callback) {
// return files.findOne({ md5: info.md5 }, function(err, existing) {
// if (err) {
// return callback(err);
// }
// if (existing) {
// infos.push(existing);
// return callback(null);
// } else {
// async.series([upload, db], callback);
// }
// });
// }
function copyIn(callback) {
if (image) {
// For images we correct automatically for common file extension mistakes
return self.uploadfs.copyImageIn(upload.path, '/files/' + file._id + '-' + file.name, function(err, result) {
if (err) {
return callback(err);
}
file.extension = result.extension;
file.width = result.width;
file.height = result.height;
file._owner = req.user;
file.searchText = self.fileSearchText(file);
if (file.width > file.height) {
file.landscape = true;
} else {
file.portrait = true;
}
return callback(null);
});
} else {
// For non-image files we have to trust the file extension
// (but we only serve it as that content type, so this should
// be reasonably safe)
return self.uploadfs.copyIn(upload.path, '/files/' + file._id + '-' + file.name + '.' + file.extension, callback);
}
}
function db(callback) {
self.pruneTemporaryProperties(file);
self.files.update({ _id: file._id }, file, function(err, count) {
return callback(err);
});
}
async.series([ md5, copyIn, db ], function(err) {
if (err) {
return self.fail(req, res);
}
return res.send({ file: file, status: 'ok' });
});
});
});
// Crop a previously uploaded image, based on the `_id` POST parameter
// and the `crop` POST parameter. `_id` should refer to an existing
// file object. `crop` should contain top, left, width and height
// properties.
//
// This route uploads a new, cropped version of
// the existing image fuke to uploadfs, named: /files/ID-NAME.top.left.width.height.extension
//
// The `crop` object is appended to the `crops` array property
// of the file object.
self.app.post('/apos/crop', function(req, res) {
var _id = req.body._id;
var crop = req.body.crop;
var file;
async.series([
function(callback) {
return callback(self.permissions.can(req, 'edit-file') ? null : 'forbidden');
},
function(callback) {
self.files.findOne({ _id: _id }, function(err, fileArg) {
file = fileArg;
return callback(err);
});
}
], function(err) {
if (!file) {
console.error(err);
return self.fail(req, res);
}
file.crops = file.crops || [];
var existing = _.find(file.crops, function(iCrop) {
if (_.isEqual(crop, iCrop)) {
return true;
}
});
if (existing) {
// We're done, this crop is already available
return res.send('OK');
}
// Pull the original out of cloud storage to a temporary folder where
// it can be cropped and popped back into uploadfs
var originalFile = '/files/' + file._id + '-' + file.name + '.' + file.extension;
var tempFile = self.uploadfs.getTempPath() + '/' + self.generateId() + '.' + file.extension;
var croppedFile = '/files/' + file._id + '-' + file.name + '.' + crop.left + '.' + crop.top + '.' + crop.width + '.' + crop.height + '.' + file.extension;
async.series([
function(callback) {
self.uploadfs.copyOut(originalFile, tempFile, callback);
},
function(callback) {
self.uploadfs.copyImageIn(tempFile, croppedFile, { crop: crop }, callback);
},
function(callback) {
file.crops.push(crop);
self.pruneTemporaryProperties(file);
self.files.update({ _id: file._id }, file, callback);
}
], function(err) {
// We're done with the temp file. We don't care if it was never created.
fs.unlink(tempFile, function() { });
if (err) {
res.statusCode = 404;
return res.send('Not Found');
} else {
return res.send('OK');
}
});
});
});
// API access to retrieve information about files. See
// the getFiles method for the available query parameters.
self.app.get('/apos/browse-files', function(req, res) {
if (!self.permissions.can(req, 'edit-file', null)) {
res.statusCode = 404;
return res.send('not found');
}
return self.browseFiles(req, req.query, function(err, result) {
if (err) {
res.statusCode = 500;
return res.send('error');
}
if (!result.files.length) {
res.statusCode = 404;
return res.send('no more');
}
return res.send(result);
});
});
// Fetch files according to the parameters specified by the
// `options` object: `owners`, `group`, `owner`, `extension`,
// `ids`, `q`, `limit`, `skip` and `minSize`. These properties
// are sanitized to ensure they are in the proper format; that makes
// this method suitable for use in the implementation of API routes.
// A wrapper for `apos.getFiles` which always sets the
// `browsing` option to restrict permissions to files
// this user is allowed to browse even if they are not
// already on a page.
self.browseFiles = function(req, options, callback) {
var newOptions = {};
if (options.group) {
newOptions.group = self.sanitizeString(options.group);
}
if (options.owner === 'user' || options.owner === 'all' ) {
newOptions.owner = options.owner;
}
newOptions.owners = self.sanitizeBoolean(options.owners);
if (options.ids) {
newOptions.ids = [];
_.each(Array.isArray(options.ids) || [], function(id) {
newOptions.ids.push(self.sanitizeString(id));
});
}
if (options.q) {
newOptions.q = self.sanitizeString(options.q);
}
if (options.extension) {
if (Array.isArray(options.extension)) {
newOptions.extension = _.filter(options.extension, function(extension) {
return self.sanitizeString(extension);
});
} else {
newOptions.extension = self.sanitizeString(options.extension);
newOptions.extension = newOptions.extension.split(',');
}
}
if (options.limit) {
newOptions.limit = self.sanitizeInteger(options.limit, 0, 0);
}
if (options.skip) {
newOptions.skip = self.sanitizeInteger(options.skip, 0, 0);
}
if (options.minSize) {
newOptions.minSize = [
self.sanitizeInteger(options.minSize[0], 0, 0),
self.sanitizeInteger(options.minSize[1], 0, 0)
];
}
if (options.ownerId) {
newOptions.ownerId = self.sanitizeId(options.ownerId);
}
if (options.tags) {
newOptions.tags = self.sanitizeTags(options.tags);
}
if (options.notTags) {
newOptions.notTags = self.sanitizeTags(options.notTags);
}
if (options.tag) {
newOptions.tag = self.sanitizeString(options.tag);
}
newOptions.browsing = true;
newOptions.trash = options.trash;
// trash is always sanitized in getFiles
return self.getFiles(req, newOptions, callback);
};
// Options are:
//
// group, extension, trash, skip, limit, q, minSize, ids, browsing
//
// The minSize option should be an array: [width, height]
//
// req is present to check identity and view permissions.
//
// Options must be pre-sanitized. See self.browseFiles
// for a wrapper that sanitizes the options so you can pass req.query.
// For performance we don't want to sanitize on every page render that
// just needs to join with previously chosen files.
//
// If the current user may edit a file it is given a ._edit = true property.
//
// For performance reasons, the _owner property is populated only if
// options.owners is true.
//
// If the `browsing` option is true, files marked private
// are returned only if this user is an admin or the owner
// of the file.
self.getFiles = function(req, options, callback) {
var criteria = {};
var limit = 10;
var skip = 0;
var q;
if (options.group) {
criteria.group = options.group;
}
if (options.ids) {
criteria._id = { $in: options.ids };
}
if (options.owner === 'user') {
criteria.ownerId = self.permissions.getEffectiveUserId(req);
}
if (options.ownerId) {
criteria.ownerId = options.ownerId;
}
if (options.tags || options.notTags) {
criteria.tags = { };
if (options.tags) {
criteria.tags.$in = options.tags;
}
if (options.notTags) {
criteria.tags.$nin = options.notTags;
}
}
if (options.extension) {
criteria.extension = { };
if (options.extension) {
criteria.extension.$in = options.extension;
}
}
if (options.tag) {
criteria.tags = { $in: [ options.tag ] };
}
self.convertBooleanFilterCriteria('trash', options, criteria, '0');
if (options.minSize) {
criteria.width = { $gte: options.minSize[0] };
criteria.height = { $gte: options.minSize[1] };
}
if (options.browsing) {
// Unless they are admin of all files, they
// cannot browse a file unless (1) they own it
// or (2) it is not private
if (!self.permissions.can(req, 'admin-file')) {
criteria.$or = [
{
ownerId: self.permissions.getEffectiveUserId(req)
},
{
private: { $ne: true }
}
];
}
}
skip = self.sanitizeInteger(options.skip, 0, 0);
limit = self.sanitizeInteger(options.limit, 0, 0, 100);
if (options.q) {
criteria.searchText = self.searchify(options.q);
}
var result = {};
var possibleEditor = false;
async.series({
permissions: function(callback) {
possibleEditor = self.permissions.can(req, 'edit-file');
return callback(null);
},
count: function(callback) {
return self.files.count(criteria, function(err, count) {
result.total = count;
return callback(err);
});
},
find: function(callback) {
return self.files.find(criteria).sort({ createdAt: -1 }).skip(skip).limit(limit).toArray(function(err, files) {
result.files = files;
// For security's sake remove this stale data that should never
// have been serialized to the database
_.each(files, function(file) {
delete file._owner;
});
self.permissions.annotate(req, 'edit-file', result.files);
return callback(err);
});
},
tags: function(callback) {
delete criteria.tags;
return self.files.distinct('tags', criteria, function(err, _tags) {
if (err) {
return callback(err);
}
result.tags = _tags;
result.tags.sort();
return callback(null);
});
},
join: function(callback) {
if (!options.owners) {
// Don't do slow things all the time
return callback(null);
}
// Dynamically rebuild the ._owner properties
return joinr.byOne(result.files, 'ownerId', '_owner', function(ids, callback) {
self.pages.find({ _id: { $in: ids } }).toArray(function(err, owners) {
if (err) {
return callback(err);
}
// For security reasons it's a terrible idea to return
// an entire person object
var newOwners = _.map(owners, function(owner) {
return _.pick(owner, '_id', 'title');
});
return callback(null, newOwners);
});
}, callback);
}
}, function(err) {
if (err) {
return callback(err);
}
return callback(null, result);
});
};
// Annotate previously uploaded files. The POST body should contain
// an array of objects with the following properties:
//
// `_id`: the id of the existing file object
//
// `title`, `description`, `credit`: strings
// `tags`: array of strings
//
// On success the response will be a JSON array of objects that
// were updated. On failure an appropriate HTTP status code is used.
self.app.post('/apos/annotate-files', function(req, res) {
// make sure we have permission to edit files at all
if (!self.permissions.can(req, 'edit-file', null)) {
res.statusCode = 400;
return res.send('invalid');
}
if (!Array.isArray(req.body)) {
res.statusCode = 400;
return res.send('invalid');
}
var criteria = { _id: { $in: _.pluck(req.body, '_id') } };
// Verify permission to edit this particular file. TODO: this
// should not be hardcoded here, but it does need to remain an
// efficient query. Classic Apostrophe media permissions: if you
// put it here, you can edit it. If you're an admin, you can edit it.
//
// Anons can edit stuff for the lifetime of their session. This
// allows for annotations and the possibility of a delete mechanism
// when crafting submissions via apostrophe-moderator.
if (!(req.user && req.user.permissions.admin)) {
criteria.ownerId = self.permissions.getEffectiveUserId(req);
}
var results = [];
return self.getFiles(req, { ids: _.pluck(req.body, '_id') }, function(err, result) {
if (err) {
console.error(err);
res.statusCode = 500;
return res.send('error');
}
return async.eachSeries(result.files, function(file, callback) {
if (!file._edit) {
// getFiles will give us files we can't edit, but it will also tell us which ones
// we can edit, so implement a permissions check here
return callback('notyours');
}
var annotation = _.find(req.body, function(item) {
return item._id === file._id;
});
if (!annotation) {
return callback('unexpected');
}
file.title = self.sanitizeString(annotation.title);
file.description = self.sanitizeString(annotation.description);
file.credit = self.sanitizeString(annotation.credit);
file.tags = self.sanitizeTags(annotation.tags);
file._owner = req.user;
file.private = self.sanitizeBoolean(annotation.private);
file.searchText = self.fileSearchText(file);
self.pruneTemporaryProperties(file);
results.push(file);
return self.files.update({ _id: file._id }, file, callback);
}, function(err) {
if (err) {
res.statusCode = 500;
return res.send('error');
}
return res.send(results);
});
});
});
// Delete a previously uploaded file. The _id POST parameter
// determines the file to be deleted. On success a JSON object
// with a `status` property containing `ok` is sent, otherwise
// status contains an error string.
self.app.post('/apos/delete-file', function(req, res) {
return self.updateTrash(req, req.body && req.body._id, true, function(err) {
if (err){
console.log(err);
}
return res.send({ 'status': err ? 'notfound' : 'ok' });
});
});
// Rescue a previously deleted file, as specified by the _id POST parameter.
// On success a JSON object with a `status` property containing `ok` is sent,
// otherwise status contains an error string.
self.app.post('/apos/rescue-file', function(req, res) {
return self.updateTrash(req, req.body && req.body._id, false, function(err) {
return res.send({ 'status': err ? 'notfound' : 'ok' });
});
});
/**
* Move the specified file in or out of the trash. If trash is true,
* trash it, otherwise rescue it.
* @param {Request} req request object
* @param {string} id id of file
* @param {boolean} trash true for trash, false for rescue
* @param {Function} callback Receives error if any
*/
self.updateTrash = function(req, id, trash, callback) {
id = self.sanitizeString(id);
if (!id.length) {
return callback('invalid');
}
var results = [];
var info;
var criteria = {
_id: id
};
if (trash) {
criteria.trash = { $ne: true };
} else {
criteria.trash = true;
}
return async.series({
get: function(callback) {
return self.files.findOne(criteria, function(err, _info) {
info = _info;
if (err) {
return callback(err);
}
if (!info) {
return callback('notfound');
}
return callback(null);
});
},
permissions: function(callback) {
return callback(self.permissions.can(req, 'edit-file', info) ? null : 'forbidden');
},
update: function(callback) {
var update;
if (trash) {
update = { $set: { trash: true } };
} else {
update = { $unset: { trash: 1 } };
}
return self.files.update(criteria,
update, function(err, count) {
if (err || (!count)) {
return callback('notfound');
} else {
return callback(null);
}
});
},
uploadfs: function(callback) {
return self.hideInUploadfs(info, trash, callback);
}
}, callback);
};
// Given a file object, hide it in uploadfs (if trash is true),
// or make it web-accessible again (if trash is false). Normally
// called only by apos.updateTrash but it is also invoked by the
// apostrophe:hide-trash legacy migration task.
self.hideInUploadfs = function(file, trash, callback) {
var info = file;
return async.series({
disableOriginal: function(callback) {
var name = '/files/' + info._id + '-' + info.name + '.' + info.extension;
var method = trash ? self.uploadfs.disable : self.uploadfs.enable;
return method(name, callback);
},
disableSizes: function(callback) {
if (info.group !== 'images') {
return callback(null);
}
return async.eachSeries(self.uploadfs.getImageSizes(),
function(size, callback) {
if (_.contains(self.trashImageSizes, size.name)) {
// We preserve this particular size for the sake of
// the admin interface to the trash folder
return callback(null);
}
var name = '/files/' + info._id + '-' + info.name + '.' + size.name + '.' + info.extension;
var method = trash ? self.uploadfs.disable : self.uploadfs.enable;
return method(name, callback);
}, callback);
}
}, callback);
};
// Determine the search text for a file object, based on its
// filename, title, credit, tags, description, extension, group
// (images or office), and owner's name.
//
// The callback is optional. If it is provided, and
// file._owner is not already set, the owner will be
// fetched on the fly.
self.fileSearchText = function(file, callback) {
function _fileSearchText(file) {
return _.map([ file.name, file.title, file.credit ].concat(
file.tags || []).concat(
[file.description, file.extension, file.group ]).concat(
(file.extension === 'jpg') ? [ 'jpeg '] : []).concat(
(file._owner ? [ file._owner.title ] : [])),
function(s) {
return self.sortify(s);
}
).join(' ');
}
if (arguments.length === 1) {
return _fileSearchText(file);
}
var owner;
var s;
return async.series({
getOwner: function(callback) {
// A workaround for the hardcoded admin user
if (file.ownerId === 'admin') {
file._owner = { title: 'admin' };
}
// If already known we can skip the query
if (file._owner) {
return setImmediate(callback);
}
self.pages.findOne({ _id: file.ownerId }, function(err, _owner) {
if (err) {
return callback(err);
}
file._owner = _owner;
return callback(null);
});
},
searchText: function(callback) {
s = _fileSearchText(file);
return callback(null);
}
}, function(err) {
if (err) {
return callback(err);
}
return callback(null, s);
});
};
// Perform an md5 checksum on a file. Returns hex string. Via:
// http://nodejs.org/api/crypto.html
self.md5File = function(filename, callback) {
var fs = require('fs');
var md5 = crypto.createHash('md5');
var s = fs.ReadStream(filename);
s.on('data', function(d) {
md5.update(d);
});
s.on('error', function(err) {
return callback(err);
});
s.on('end', function() {
var d = md5.digest('hex');
return callback(null, d);
});
};
// Perform an md5 checksum on a string. Returns hex string.
self.md5 = function(s) {
var md5 = crypto.createHash('md5');
md5.update(s);
return md5.digest('hex');
};
// Given a file object (as found in a slideshow widget for instance),
// return the file URL. If options.size is set, return the URL for
// that size (one-third, one-half, two-thirds, full). full is
// "full width" (1140px), not the original. For the original, don't pass size.
// If the "uploadfsPath" option is true, an
// uploadfs path is returned instead of a URL.
// There is a matching client-side implementation accessible as apos.filePath
self.filePath = function(file, options) {
options = options || {};
var path = '/files/' + file._id + '-' + file.name;
if (!options.uploadfsPath) {
path = self.uploadfs.getUrl() + path;
}
if (file.crop) {
var c = file.crop;
path += '.' + c.left + '.' + c.top + '.' + c.width + '.' + c.height;
}
if (options.size) {
path += '.' + options.size;
}
return path + '.' + file.extension;
};
}
};