@meese-os/server
Version:
meeseOS Server
563 lines (508 loc) • 15.7 kB
JavaScript
/**
* OS.js - JavaScript Cloud/Web Desktop Platform
*
* Copyright (c) 2011-Present, 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 path = require("path");
const fh = require("filehound");
const chokidar = require("chokidar");
const extract = require("extract-zip");
const yazl = require("yazl");
/**
* Creates an object readable by the client.
*
* @param {Core} core MeeseOS Core instance reference
* @param {String} realRoot Real root path
* @param {String} file File path
* @returns {Object} Information about the file
*/
const createFileIter = (core, realRoot, file) => {
const filename = path.basename(file);
const realPath = path.join(realRoot, filename);
const { mime } = core.make("meeseOS/vfs");
const createStat = (stat) => ({
isDirectory: stat.isDirectory(),
isFile: stat.isFile(),
mime: stat.isFile() ? mime(realPath) : null,
size: stat.size,
path: file,
filename,
stat,
});
return fs
.stat(realPath)
.then(createStat)
.catch((error) => {
core.logger.warn(error);
return createStat({
isDirectory: () => false,
isFile: () => true,
size: 0,
});
});
};
/**
* Segment value map.
*/
const segments = {
root: {
dynamic: false,
fn: () => process.cwd(),
},
vfs: {
dynamic: false,
fn: (core) => core.config("vfs.root", process.cwd()),
},
username: {
dynamic: true,
fn: (core, session) => session.user.username,
},
};
/**
* Gets a segment value.
*
* @param {Core} core MeeseOS Core instance reference
* @param {Object} session
* @param {String} segment
* @returns {String}
*/
const getSegment = (core, session, seg) =>
segments[seg] ? segments[seg].fn(core, session) : "";
/**
* Matches a string for segments.
* @param {String} str
* @returns {Array}
*/
const matchSegments = (str) => str.match(/(\{\w+\})/g) || [];
/**
* Resolves a string with segments.
*
* @param {Core} core MeeseOS Core instance reference
* @param {Object} session
* @param {String} str
* @returns {String}
*/
const resolveSegments = (core, session, str) =>
matchSegments(str).reduce(
(result, current) =>
result.replace(
current,
getSegment(core, session, current.replace(/(\{|\})/g, ""))
),
str
);
/**
* Resolves a given file path based on a request.
* Will take out segments from the resulting string
* and replace them with a list of defined variables.
*
* @param {Core} core MeeseOS Core instance reference
* @param {Object} session
* @param {Object} mount
* @param {String} file
* @returns {String}
*/
const getRealPath = (core, session, mount, file) => {
const root = resolveSegments(core, session, mount.attributes.root);
const str = file.substring(mount.root.length - 1);
return path.join(root, str);
};
/**
* System VFS adapter.
* @param {Core} core MeeseOS Core instance reference
* @param {Object} [options] Adapter options
*/
module.exports = (core) => {
/**
* Wrapper for cross-adapter methods.
* @param {String} method The method to wrap
* @param {Function} cb The callback to use
* @param {...any} args The arguments to pass to the method
* @returns {Function}
*/
const wrapper = (method, cb, ...args) =>
(vfs) =>
(file, options = {}) => {
const promise = Promise.resolve(
getRealPath(core, options.session, vfs.mount, file)
).then((realPath) => fs[method](realPath, ...args));
return typeof cb === "function"
? cb(promise, options)
: promise.then(() => true);
};
/**
* Cross-adapter wrapper.
* @param {String} method The method to wrap
* @returns {Function}
*/
const crossWrapper = (method) =>
(srcVfs, destVfs) =>
(src, dest, options = {}) =>
Promise.resolve({
realSource: getRealPath(core, options.session, srcVfs.mount, src),
realDest: getRealPath(core, options.session, destVfs.mount, dest),
})
.then(({ realSource, realDest }) => fs[method](realSource, realDest))
.then(() => true);
/**
* Adds a file to the archive.
* @param {yazl.ZipFile} zipfile The ZIP file to add to
* @param {String} realPath The real path to the file
*/
const addFileToArchive = (zipfile, realPath) => {
const zipfileLocation = zipfile.outputStream._readableState.pipes[0].path;
const archiveRoot = path.parse(zipfileLocation).dir + path.sep;
const readStream = fs.createReadStream(realPath);
const destination = realPath.replace(archiveRoot, "");
zipfile.addReadStream(readStream, destination);
};
/**
* Adds a directory to the archive.
* @param {yazl.ZipFile} zipfile The ZIP file to add to
* @param {String} realPath The real path to the file/directory
*/
const addDirToArchive = async (zipfile, realPath) => {
const files = await fs.readdir(realPath)
.then((files) => ({ realPath, files }))
.then(({ realPath, files }) => {
const promises = files.map((fileName) => {
const filePath = realPath.replace(/\/?$/, "/") + fileName;
return createFileIter(
core,
path.dirname(filePath),
filePath
);
});
return Promise.all(promises);
});
for (const file of files) {
if (file.isDirectory) {
await addToArchive(zipfile, file.path);
} else {
addFileToArchive(zipfile, file.path);
}
}
};
/**
* Adds everything in the given directory to the VFS archive.
* @param {yazl.ZipFile} zipfile The ZIP file to add to
* @param {String} realPath The real path to the file/directory
*/
const addToArchive = async (zipfile, realPath) => {
const isDirectory = await fs.stat(realPath).then((stat) => stat.isDirectory());
if (isDirectory) {
await addDirToArchive(zipfile, realPath);
} else {
await addFileToArchive(zipfile, realPath);
}
// Remove the original file or directory after adding to the archive
await fs.remove(realPath);
};
return {
watch: (mount, callback) => {
const dest = resolveSegments(
core,
{
user: {
username: "**",
},
},
mount.attributes.root
);
const watch = chokidar.watch(dest, mount.attributes.chokidar || {});
const restr = dest.replace(/\*\*/g, "([^/]*)");
const re = new RegExp(restr + "/(.*)");
const seg = matchSegments(mount.attributes.root)
.map((s) => s.replace(/\{|\}/g, ""))
.filter((s) => segments[s].dynamic);
const handle = (name) => (file) => {
const test = re.exec(file);
if (test && test.length > 0) {
const args = seg.reduce((res, k, i) => ({ [k]: test[i + 1] }), {});
callback(args, test[test.length - 1], name);
}
};
const events = ["add", "addDir", "unlinkDir", "unlink"];
events.forEach((name) => watch.on(name, handle(name)));
return watch;
},
/**
* Get filesystem capabilities.
* @param {String} file The file path from client
* @param {Object} [options={}] Options
* @return {Object[]}
*/
capabilities: (_vfs) =>
(_file, _options = {}) =>
Promise.resolve({
sort: false,
pagination: false
}),
/**
* Gets the real filesystem path (internal only).
* @param {String} file The file path from client
* @param {Object} [options={}] Options
* @returns {String}
*/
realpath: (vfs) =>
(file, options = {}) =>
Promise.resolve(getRealPath(core, options.session, vfs.mount, file)),
/**
* Checks if file exists.
* @param {String} file The file path from client
* @param {Object} [options={}] Options
* @returns {Promise<Boolean, Error>}
*/
exists: wrapper(
"access",
(promise) => {
return promise.then(() => true).catch(() => false);
},
fs.F_OK
),
/**
* Get file statistics.
* @param {String} file The file path from client
* @param {Object} [options={}] Options
* @returns {Object}
*/
stat: (vfs) =>
(file, options = {}) =>
Promise.resolve(
getRealPath(core, options.session, vfs.mount, file)
).then((realPath) => {
return fs
.access(realPath, fs.F_OK)
.then(() => createFileIter(core, path.dirname(realPath), realPath));
}),
/**
* Reads directory.
* @param {String} root The file path from client
* @param {Object} [options={}] Options
* @returns {Object[]}
*/
readdir: (vfs) =>
(root, options) =>
Promise.resolve(getRealPath(core, options.session, vfs.mount, root))
.then((realPath) =>
fs.readdir(realPath).then((files) => ({ realPath, files }))
)
.then(({ realPath, files }) => {
const promises = files.map((file) =>
createFileIter(core, realPath, root.replace(/\/?$/, "/") + file)
);
return Promise.all(promises);
}),
/**
* Reads file stream.
* @param {String} file The file path from client
* @param {Object} [options={}] Options
* @returns {Stream.Readable}
*/
readfile: (vfs) =>
(file, options = {}) =>
Promise.resolve(getRealPath(core, options.session, vfs.mount, file))
.then((realPath) =>
fs.stat(realPath).then((stat) => ({ realPath, stat }))
)
.then(({ realPath, stat }) => {
if (!stat.isFile()) {
return false;
}
const range = options.range || [];
return fs.createReadStream(realPath, {
flags: "r",
start: range[0],
end: range[1],
});
}),
/**
* Creates directory.
* @param {String} file The file path from client
* @param {Object} [options={}] Options
* @returns {Boolean}
*/
mkdir: wrapper("mkdir", (promise, options = {}) => {
return promise
.then(() => true)
.catch((e) => {
if (options.ensure && e.code === "EEXIST") {
return true;
}
return Promise.reject(e);
});
}),
/**
* Writes file stream.
*
* @param {String} file The file path from client
* @param {Stream.Readable} data The stream
* @param {Object} [options={}] Options
* @returns {Promise<Boolean, Error>}
*/
writefile: (vfs) =>
(file, data, options = {}) =>
new Promise((resolve, reject) => {
// FIXME: Currently this actually copies the file because
// formidable will put this in a temporary directory.
// It would probably be better to do a "rename()" on local filesystems
const realPath = getRealPath(core, options.session, vfs.mount, file);
const write = () => {
const stream = fs.createWriteStream(realPath);
data.on("error", (err) => reject(err));
data.on("end", () => resolve(true));
data.pipe(stream);
};
fs.stat(realPath)
.then((stat) => {
if (stat.isDirectory()) {
resolve(false);
} else {
write();
}
})
// We are not worried about the file not existing, so if that is
// the error message, we can just go ahead and write to create it.
.catch((err) => (err.code === "ENOENT" ? write() : reject(err)));
}),
/**
* Renames given file or directory.
*
* @param {String} src The source file path from client
* @param {String} dest The destination file path from client
* @param {Object} [options={}] Options
* @returns {Boolean}
*/
rename: crossWrapper("rename"),
/**
* Copies given file or directory.
*
* @param {String} src The source file path from client
* @param {String} dest The destination file path from client
* @param {Object} [options={}] Options
* @returns {Boolean}
*/
copy: crossWrapper("copy"),
/**
* Removes given file or directory.
* @param {String} file The file path from client
* @param {Object} [options={}] Options
* @returns {Boolean}
*/
unlink: wrapper("remove"),
/**
* Searches for files and folders.
* @param {String} file The file path from client
* @param {Object} [options={}] Options
* @returns {Promise<Object>}
*/
search: (vfs) =>
(root, pattern, options = {}) =>
Promise.resolve(getRealPath(core, options.session, vfs.mount, root))
.then((realPath) => {
return fh
.create()
.paths(realPath)
.match(pattern)
.find()
.then((files) => ({ realPath, files }))
.catch((err) => {
core.logger.warn(err);
return { realPath, files: [] };
});
})
.then(({ realPath, files }) => {
const promises = files.map((file) => {
const rf = file.substr(realPath.length);
return createFileIter(
core,
path.dirname(realPath.replace(/\/?$/, "/") + rf),
root.replace(/\/?$/, "/") + rf
);
});
return Promise.all(promises);
}),
/**
* Touches a file.
* @param {String} file The file path from client
* @param {Object} [options={}] Options
* @returns {Boolean}
*/
touch: wrapper("ensureFile"),
/**
* Compresses or decompresses a given selection.
* @param {Array} selection The selection from the client
* @param {Object} [options={}] Options
* @returns {Promise<Boolean, Error>}
*/
archive: (vfs) =>
async (selection, options = {}) => {
const realPaths = selection.map(
(file) => getRealPath(core, options.session, vfs.mount, file)
);
const action = options.action ?? "compress";
switch (action) {
case "compress": {
// Define the archive instance
const zipfile = new yazl.ZipFile();
// Create a stream for the archive output
// IDEA: Dialog for the archive name on the client side?
const output = fs.createWriteStream(realPaths[0] + ".zip");
zipfile.outputStream.pipe(output);
try {
// Add the files to the archive
for (const realPath of realPaths) {
await addToArchive(zipfile, realPath);
}
zipfile.end();
} catch (err) {
// If there is an error, end the archive and delete the file
zipfile.end();
fs.unlink(realPaths[0] + ".zip");
throw err;
}
break;
}
case "extract":
for (const realPath of realPaths) {
// Remove the `.zip` extension from the path
// IDEA: Dialog for the target name on the client side?
const target = realPath.split(".").slice(0, -1).join(".");
// Extract the archive
await extract(realPath, { dir: target });
}
break;
default:
return Promise.reject(
new Error(`Unknown archive action: '${action}'`)
);
}
return Promise.resolve(true);
}
};
};