src/utils/vfs.js
/*
* OS.js - JavaScript Cloud/Web Desktop Platform
*
* Copyright (c) Anders Evenrud <andersevenrud@gmail.com>
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* @author Anders Evenrud <andersevenrud@gmail.com>
* @licence Simplified BSD License
*/
const fs = require('fs-extra');
const url = require('url');
const sanitizeFilename = require('sanitize-filename');
const formidable = require('formidable');
const {Stream} = require('stream');
/**
* A map of error codes
*/
const errorCodes = {
ENOENT: 404,
EACCES: 401
};
/**
* Gets prefix of a VFS path
*/
const getPrefix = path => String(path).split(':')[0];
/**
* Sanitizes a path
*/
const sanitize = filename => {
const [name, str] = (filename.replace(/\/+/g, '/')
.match(/^([\w-_]+):+(.*)/) || [])
.slice(1);
const sane = str.split('/')
.map(s => sanitizeFilename(s))
.join('/')
.replace(/\/+/g, '/');
return name + ':' + sane;
};
/**
* Gets the stream from a HTTP request
*/
const streamFromRequest = req => {
const isStream = req.files.upload instanceof Stream;
return isStream
? req.files.upload
: fs.createReadStream(req.files.upload.path);
};
const validateAll = (arr, compare, strict = true) => arr[strict ? 'every' : 'some'](g => compare.indexOf(g) !== -1);
/**
* Validates array groups
*/
const validateNamedGroups = (groups, userGroups, strict) => {
const namedGroups = groups
.filter(g => typeof g === 'string');
return namedGroups.length
? validateAll(namedGroups, userGroups, strict)
: true;
};
/**
* Validates matp of groups based on method:[group,...]
*/
const validateMethodGroups = (groups, userGroups, method, strict) => {
const methodGroups = groups
.find(g => typeof g === 'string' ? false : (method in g));
return methodGroups
? validateAll(methodGroups[method], userGroups, strict)
: true;
};
/**
* Validates groups
*/
const validateGroups = (userGroups, method, mountpoint, strict) => {
const groups = mountpoint.attributes.groups || [];
if (groups.length) {
const namedValid = validateNamedGroups(groups, userGroups, strict);
const methodValid = validateMethodGroups(groups, userGroups, method, strict);
return namedValid && methodValid;
}
return true;
};
/**
* Checks permissions for given mountpoint
*/
const checkMountpointPermission = (req, res, method, readOnly, strict) => {
const userGroups = req.session.user.groups;
return ({mount}) => {
if (readOnly) {
const {attributes, name} = mount;
if (attributes.readOnly) {
const failed = typeof readOnly === 'function'
? getPrefix(readOnly(req, res)) === name
: readOnly;
if (failed) {
return Promise.reject(createError(403, `Mountpoint '${name}' is read-only`));
}
}
}
if (validateGroups(userGroups, method, mount, strict)) {
return Promise.resolve(true);
}
return Promise.reject(createError(403, `Permission was denied for '${method}' in '${mount.name}'`));
};
};
/**
* Creates a new custom Error
*/
const createError = (code, message) => {
const e = new Error(message);
e.code = code;
return e;
};
/**
* Resolves a mountpoint
*/
const mountpointResolver = core => async (path) => {
const {adapters, mountpoints} = core.make('osjs/vfs');
const prefix = getPrefix(path);
const mount = mountpoints.find(m => m.name === prefix);
if (!mount) {
throw createError(403, `Mountpoint not found for '${prefix}'`);
}
const adapter = await (mount.adapter
? adapters[mount.adapter]
: adapters.system);
return Object.freeze({mount, adapter});
};
/*
* Assembles a given object query
*/
const assembleQueryData = (data) => {
const entries = Object
.entries(data)
.map(([k, v]) => {
try {
return [k, JSON.parse(v)];
} catch (e) {
return [k, v];
}
});
return Object.fromEntries(entries);
};
/*
* Parses URL Body
*/
const parseGet = req => {
const {query} = url.parse(req.url, true);
const assembledQuery = assembleQueryData(query);
return Promise.resolve({fields: assembledQuery, files: {}});
};
/*
* Parses Json Body
*/
const parseJson = req => {
const isJson = req.headers['content-type'] &&
req.headers['content-type'].indexOf('application/json') !== -1;
if (isJson) {
return {fields: req.body, files: {}};
}
return false;
};
/*
* Parses Form Body
*/
const parseFormData = (req, {maxFieldsSize, maxFileSize}) => {
const form = new formidable.IncomingForm();
form.maxFieldsSize = maxFieldsSize;
form.maxFileSize = maxFileSize;
return new Promise((resolve, reject) => {
form.parse(req, (err, fields, files) => {
return err ? reject(err) : resolve({fields, files});
});
});
};
/**
* Middleware for handling HTTP requests
*/
const parseFields = config => (req, res) => {
if (['get', 'head'].indexOf(req.method.toLowerCase()) !== -1) {
return Promise.resolve(parseGet(req));
}
const json = parseJson(req);
if (json) {
return Promise.resolve(json);
}
return parseFormData(req, config);
};
/**
* A map of methods and their arguments.
* Used for direct access via API
*/
const methodArguments = {
realpath: ['path'],
exists: ['path'],
stat: ['path'],
readdir: ['path'],
readfile: ['path'],
writefile: ['path', upload => ({upload})],
mkdir: ['path'],
unlink: ['path'],
touch: ['path'],
search: ['root', 'pattern'],
copy: ['from', 'to'],
rename: ['from', 'to']
};
module.exports = {
mountpointResolver,
createError,
checkMountpointPermission,
validateGroups,
streamFromRequest,
sanitize,
getPrefix,
parseFields,
errorCodes,
methodArguments,
assembleQueryData
};