/*******************************************************************************
* Copyright (c) 2012, 2016 IBM Corporation and others.
* All rights reserved. This program and the accompanying materials are made
* available under the terms of the Eclipse Public License v1.0
* (http://www.eclipse.org/legal/epl-v10.html), and the Eclipse Distribution
* License v1.0 (http://www.eclipse.org/org/documents/edl-v10.html).
*
* Contributors:
* IBM Corporation - initial API and implementation
*******************************************************************************/
/*eslint-env node*/
/*eslint-disable consistent-return*/
var ETag = require('./util/etag');
var path = require('path');
var Promise = require('bluebird');
var rimraf = require('rimraf');
var fse = require('fs-extra');
var api = require('./api');
var fs = Promise.promisifyAll(require('fs'));
/*
* Utils for representing files as objects in the Orion File API
* http://wiki.eclipse.org/Orion/Server_API/File_API
*/
/**
* Builds up an array of a directory's children, as File objects.
* @param {String} parentLocation Parent location in the file api for child items (ugh)
* @param {Array} [exclude] Filenames of children to hide. If `null`, everything is included.
* @param {Function} callback Invoked as func(error?, children)
* @returns A promise
*/
var getChildren = exports.getChildren = function(fileRoot, workspaceDir, directory, depth, excludes) {
return fs.readdirAsync(directory)
.then(function(files) {
return Promise.map(files, function(file) {
if (Array.isArray(excludes) && excludes.indexOf(file) !== -1) {
return null; // omit
}
var filepath = path.join(directory, file);
return fs.statAsync(filepath)
.then(function(stats) {
return fileJSON(fileRoot, workspaceDir, filepath, stats, depth ? depth - 1 : 0);
})
.catch(function() {
return null; // suppress rejection
});
});
})
.then(function(results) {
return results.filter(function(r) { return r; });
});
};
/**
* @parma {String} p A location in the local filesystem (eg C:\\Users\\whatever\\foo)
* @throws {Error} If p is outside the workspaceDir (and thus is unsafe)
*/
var safePath = exports.safePath = function(workspaceDir, p) {
workspaceDir = path.normalize(workspaceDir);
p = path.normalize(p);
var relativePath = path.relative(workspaceDir, p);
if (relativePath.indexOf('..' + path.sep) === 0) {
throw new Error('Path ' + p + ' is outside workspace');
}
return p;
};
/**
* @param {String} filepath The URL-encoded path, for example 'foo/My%20Work/baz.txt'
* @returns {String} The filesystem path represented by interpreting 'path' relative to the workspace dir.
* The returned value is URL-decoded.
* @throws {Error} If rest is outside of the workspaceDir (and thus is unsafe)
*/
var safeFilePath = exports.safeFilePath = function(workspaceDir, filepath) {
return safePath(workspaceDir, path.join(workspaceDir, filepath));
};
var getParents = exports.getParents = function(fileRoot, relativePath) {
var segs = relativePath.split('/');
if(segs && segs.length > 0 && segs[segs.length-1] === ""){// pop the last segment if it is empty. In this case wwwpath ends with "/".
segs.pop();
}
segs.pop();//The last segment now is the directory itself. We do not need it in the parents array.
var loc = fileRoot;
var parents = [];
for (var i=0; i < segs.length; i++) {
var seg = segs[i];
loc = api.join(loc, seg);
var location = loc + "/";
parents.push({
Name: seg,
ChildrenLocation: {pathname: location, query: {depth:1}},
Location: location
});
}
return parents.reverse();
};
/**
* Performs the equivalent of rm -rf on a directory.
* @param {Function} callback Invoked as callback(error)
*/
exports.rumRuff = function(dirpath, callback) {
rimraf(dirpath, callback);
};
/**
* Copy srcPath to destPath
* @param {String} srcPath
* @param {String} destPath
* @param {Function} callback Invoked as callback(error?, destPath)
* @returns promise
*/
var copy = exports.copy = function(srcPath, destPath, callback) {
return new Promise(function(fulfill, reject) {
return fse.copy(srcPath, destPath, {clobber: true, limit: 32}, function(err) {
if (err) {
if (callback) callback(err);
return reject(err);
}
if (callback) callback(null, destPath);
fulfill(destPath);
});
});
};
/**
* @param {Function} callback Invoked as callback(error, stats)
* @deprecated just use Promise.promisify(fs).statAsync() instead
*/
exports.withStats = function(filepath, callback) {
fs.stat(filepath, function(error, stats) {
if (error) { callback(error); }
else {
callback(null, stats);
}
});
};
exports.decodeSlug = function(slug) {
if (typeof slug === "string") return decodeURIComponent(slug);
return slug;
};
/**
* Gets the stats for filepath and calculates the ETag based on the bytes in the file.
* @param {Function} callback Invoked as callback(error, stats, etag) -- the etag can be null if filepath represents a directory.
*/
exports.withStatsAndETag = function(filepath, callback) {
fs.stat(filepath, function(error, stats) {
if (error) {
callback(error);
return;
}
if (!stats.isFile()) {
// no etag
callback(null, stats, null);
return;
}
var etag = ETag();
var stream = fs.createReadStream(filepath);
stream.pipe(etag);
stream.on('error', callback);
stream.on('end', function() {
callback(null, stats, etag.read());
});
});
};
exports.withETag = function(filepath, callback) {
var etag = ETag();
var stream = fs.createReadStream(filepath);
stream.pipe(etag);
stream.on('error', callback);
stream.on('end', function() {
callback(null, etag.read());
});
};
/**
* @returns {String} The Location of for a file resource.
*/
function getFileLocation(fileRoot, wwwpath, isDir) {
return api.join(fileRoot, wwwpath) + (isDir ? '/' : '');
}
/**
* Gets a boolean associated with a key. Copes with Orion server REST API's use of "true" and "false" strings.
* @param {Object} obj
* @param {String} key
* @returns {Boolean} Returns false
if there is no such key, or if the value is not the boolean true
* or the string "true"
.
*/
function getBoolean(obj, key) {
var val = obj[key];
return Object.prototype.hasOwnProperty.call(obj, key) && (val === true || val === 'true');
}
var decorators = [];
/**
* Shared decorator, used by workspace as well.
*/
exports.getDecorators = function(){
return decorators;
};
/**
* Used to add different decorators to generate respond json.
* @param {func} decorator functions to be added;
*/
exports.addDecorator = function(func) {
decorators.push(func);
};
/**
* Helper for fulfilling a file metadata GET request.
* @param {String} fileRoot The "/file" prefix or equivalent.
* @param {Object} req HTTP request object
* @param {Object} res HTTP response object
* @param {String} filepath The physical path to the file on the server.
* @param {Object} stats
* @param {String} etag
* @param {Boolean} [includeChildren=false]
* @param {Object} [metadataMixins] Additional metadata to mix in to the response object.
*/
var writeFileMetadata = exports.writeFileMetadata = function(fileRoot, req, res, filepath, stats, etag, depth, metadataMixins) {
var result;
return fileJSON(fileRoot, req.user.workspaceDir, filepath, stats, depth, metadataMixins)
.then(function(originalJson) {
result = originalJson;
return Promise.map(decorators, function(decorator){
return decorator(fileRoot, req, filepath, result);
});
})
.then(function(){
if (etag) {
result.ETag = etag;
res.setHeader('ETag', etag);
}
res.setHeader("Cache-Control", "no-cache");
api.write(null, res, null, result);
})
.catch(api.writeError.bind(null, 500, res));
};
function fileJSON(fileRoot, workspaceDir, filepath, stats, depth, metadataMixins) {
depth = depth || 0;
var isDir = stats.isDirectory();
var wwwpath = api.toURLPath(filepath.substring(workspaceDir.length + 1));
var result = {
Name: path.basename(filepath),
Location: getFileLocation(fileRoot, wwwpath, isDir),
Directory: isDir,
LocalTimeStamp: stats.mtime.getTime(),
Parents: getParents(fileRoot, wwwpath),
Attributes: {
// TODO fix this
ReadOnly: false, //!(stats.mode & USER_WRITE_FLAG === USER_WRITE_FLAG),
Executable: false //!(stats.mode & USER_EXECUTE_FLAG === USER_EXECUTE_FLAG)
}
};
if (metadataMixins) {
Object.keys(metadataMixins).forEach(function(property) {
result[property] = metadataMixins[property];
});
}
if (!isDir) {
return Promise.resolve(result);
}
// Orion's File Client expects ChildrenLocation to always be present
result.ChildrenLocation = {pathname: result.Location, query: {depth:1}};
result.ImportLocation = result.Location.replace(/\/file/, "/xfer/import").replace(/\/$/, "");
result.ExportLocation = result.Location.replace(/\/file/, "/xfer/export").replace(/\/$/, "") + ".zip";
if (depth <= 0) {
return Promise.resolve(result);
}
return getChildren(fileRoot, workspaceDir, filepath, depth)
.then(function(children) {
result.Children = children;
return result;
});
}
/**
* Helper for fulfilling a file POST request (for example, copy, move, or create).
* @param {String} fileRoot The route of the /file handler (not including context path)
* @param {Object} req
* @parma {Object} res
* @param {String} wwwpath
* @param {String} destFilepath
* @param {Object} [metadata] Additional metadata to be mixed in to the File response.
* @param {Number} [statusCode] Status code to send on a successful response. By default, `201 Created` is sent if
* a new resource was created, and and `200 OK` if an existing resource was overwritten.
*/
exports.handleFilePOST = function(fileRoot, req, res, destFilepath, metadataMixins, statusCode) {
var isDirectory = req.body && getBoolean(req.body, 'Directory');
var writeResponse = function(isOverwrite) {
// TODO: maybe set ReadOnly and Executable based on fileAtts
if (typeof statusCode === 'number') {
res.status(statusCode);
} else {
// Status code 200 indicates that an existing resource was replaced, or we're POSTing to a URL
res.status(isOverwrite ? 200 : 201);
}
return fs.statAsync(destFilepath)
.then(function(stats) {
return writeFileMetadata(fileRoot, req, res, destFilepath, stats, /*etag*/null, /*depth*/0, metadataMixins);
})
.catch(function(err) {
api.writeError(500, res, err.message);
});
};
fs.statAsync(destFilepath)
.catchReturn({ code: 'ENOENT' }, null) // suppress reject when file does not exist
.then(function(stats) {
return !!stats; // just return whether the file existed
})
.then(function(destExists) {
var xCreateOptions = (req.headers['x-create-options'] || "").split(",");
var isCopy = xCreateOptions.indexOf('copy') !== -1, isMove = xCreateOptions.indexOf('move') !== -1;
if (isCopy && isMove) {
return api.write(400, res, null, 'Illegal combination of X-Create-Options.');
}
if (xCreateOptions.indexOf('no-overwrite') !== -1 && destExists) {
return api.writeError(412, res, new Error('A file or folder with the same name already exists at this location.'));
}
if (isCopy || isMove) {
var sourceUrl = req.body.Location;
if (!sourceUrl) {
return api.writeError(400, res, 'Missing Location property in request body');
}
var sourceFilepath = safeFilePath(req.user.workspaceDir, api.rest(fileRoot, api.matchHost(req, sourceUrl)));
return fs.statAsync(sourceFilepath)
.then(function(/*stats*/) {
return isCopy ? copy(sourceFilepath, destFilepath) : fs.renameAsync(sourceFilepath, destFilepath);
})
.then(writeResponse.bind(null, destExists))
.catch(function(err) {
if (err.code === 'ENOENT') {
return api.writeError(404, res, 'File not found:' + sourceUrl);
}
return api.writeError(500, res, err);
});
}
// Just a regular file write
return Promise.resolve()
.then(function(){
return destExists ? fs.unlinkAsync(destFilepath) : null;
})
.then(function() {
return isDirectory ? fs.mkdirAsync(destFilepath) : fs.writeFileAsync(destFilepath, '');
})
.then(writeResponse.bind(null, destExists))
.catch(api.writeError.bind(null, 500, res));
});
};