@meese-os/server
Version:
meeseOS Server
372 lines (326 loc) • 10.9 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 express = require("express");
const { Stream } = require("stream");
const {
mountpointResolver,
checkMountpointPermission,
streamFromRequest,
sanitize,
parseFields,
errorCodes,
} = require("./utils/vfs");
const respondNumber = (result) => (typeof result === "number" ? result : -1);
const respondBoolean = (result) =>
typeof result === "boolean" ? result : Boolean(result);
const requestSelection = (req) => [sanitize(req.fields.selection)];
const requestPath = (req) => [sanitize(req.fields.path)];
const requestSearch = (req) => [sanitize(req.fields.root), req.fields.pattern];
const requestFile = (req) => [sanitize(req.fields.path), streamFromRequest(req)];
const requestCross = (req) => [sanitize(req.fields.from), sanitize(req.fields.to)];
/**
* Parses the range request headers.
* @param {String} range
* @returns {Array}
*/
const parseRangeHeader = (range) => {
const [pstart, pend] = range.replace(/bytes=/, "").split("-");
const start = parseInt(pstart, 10);
const end = pend ? parseInt(pend, 10) : undefined;
return [start, end];
};
/**
* A "finally" for our chain.
* @param {Request} req
* @param {Response} res
*/
const onDone = (req, _res) => {
if (req.files) {
for (const fieldname in req.files) {
try {
const n = req.files[fieldname].path;
if (fs.existsSync(n)) {
fs.removeSync(n);
}
} catch (e) {
console.warn("Failed to unlink temporary file", e);
}
}
}
};
/**
* Wraps a VFS adapter request.
* @param {Function} fn The wrapper function to apply
*/
const wrapper = (fn) =>
(req, res, next) =>
fn(req, res)
.then((result) => new Promise((resolve, reject) => {
if (result instanceof Stream) {
result.once("error", reject);
result.once("end", resolve);
result.pipe(res);
} else {
res.json(result);
resolve();
}
}))
.catch(error => next(error))
.finally(() => onDone(req, res));
/**
* Creates the middleware.
* @param {Core} core MeeseOS Core instance reference
* @returns {Function}
*/
const createMiddleware = (core) => {
const parse = parseFields(core.config("express"));
return (req, res, next) =>
parse(req, res)
.then(({ fields, files }) => {
req.fields = fields;
req.files = files;
next();
})
.catch((error) => {
core.logger.warn(error);
req.fields = {};
req.files = {};
next(error);
});
};
const createOptions = (req) => {
const options = req.fields.options;
const range = req.headers?.range;
const session = { ...(req.session || {}) };
let result = options || {};
if (typeof options === "string") {
try {
result = JSON.parse(options) || {};
} catch (e) {
// Allow to fall through
}
}
if (range) {
result.range = parseRangeHeader(req.headers.range);
}
return {
...result,
session,
};
};
/**
* Standard request with only a target.
* @param {Function} findMountpoint
* @returns {Function}
*/
const createRequestFactory = (findMountpoint) =>
(getter, method, readOnly, respond) =>
async (req, res) => {
const call = async (target, rest, options) => {
const found = await findMountpoint(target);
const attributes = found.mount.attributes || {};
const strict = attributes.strictGroups !== false;
if (method === "search") {
if (attributes.searchable === false) {
return [];
}
}
await checkMountpointPermission(req, res, method, readOnly, strict)(found);
const vfsMethodWrapper = m => {
return found.adapter[m]
? found.adapter[m](found)(target, ...rest, options)
: Promise.reject(new Error(`Adapter does not support ${m}`));
};
const result = await vfsMethodWrapper(method);
if (method === "readfile") {
const ranges = (!attributes.adapter || attributes.adapter === "system") || attributes.ranges === true;
const stat = await vfsMethodWrapper("stat").catch(() => ({}));
if (ranges && options.range) {
try {
if (stat.size) {
const size = stat.size;
const [start, end] = options.range;
const realEnd = end ? end : size - 1;
const chunksize = (realEnd - start) + 1;
res.writeHead(206, {
"Accept-Ranges": "bytes",
"Content-Length": chunksize,
"Content-Range": `bytes ${start}-${realEnd}/${size}`,
"Content-Type": stat.mime
});
}
} catch (e) {
console.warn("Failed to send a ranged response", e);
}
} else if (stat.mime) {
res.append("Content-Type", stat.mime);
}
if (options.download) {
const filename = encodeURIComponent(path.basename(target));
res.append(
"Content-Disposition",
`attachment; filename*=utf-8''${filename}`
);
}
}
return respond ? respond(result) : result;
};
return new Promise((resolve, reject) => {
const options = createOptions(req);
const [target, ...rest] = getter(req, res);
const [resource] = rest;
if (resource instanceof Stream) {
resource.once("error", reject);
}
call(target, rest, options).then(resolve).catch(reject);
});
};
/**
* Request that has a source and target.
* @param {Function} findMountpoint
* @returns {Boolean}
*/
const createCrossRequestFactory = (findMountpoint) =>
(getter, method, _respond) =>
async (req, res) => {
const [from, to, options] = [...getter(req, res), createOptions(req)];
const srcMount = await findMountpoint(from);
const destMount = await findMountpoint(to);
const sameAdapter = srcMount.adapter === destMount.adapter;
const srcStrict = srcMount.mount.attributes.strictGroups !== false;
const destStrict = destMount.mount.attributes.strictGroups !== false;
await checkMountpointPermission(
req,
res,
"readfile",
false,
srcStrict
)(srcMount);
await checkMountpointPermission(
req,
res,
"writefile",
true,
destStrict
)(destMount);
if (sameAdapter) {
const result = await srcMount.adapter[method](srcMount, destMount)(
from,
to,
options
);
return Boolean(result);
}
// Simulates a copy/move
const stream = await srcMount.adapter.readfile(srcMount)(from, options);
const result = await destMount.adapter.writefile(destMount)(
to,
stream,
options
);
if (method === "rename") {
await srcMount.adapter.unlink(srcMount)(from, options);
}
return Boolean(result);
};
/**
* VFS Methods
* @param {Core} core MeeseOS Core instance reference
* @returns {Object}
*/
const vfs = (core) => {
const findMountpoint = mountpointResolver(core);
const createRequest = createRequestFactory(findMountpoint);
const createCrossRequest = createCrossRequestFactory(findMountpoint);
// Wire up all available VFS events
return {
capabilities: createRequest(requestPath, "capabilities", false),
realpath: createRequest(requestPath, "realpath", false),
exists: createRequest(requestPath, "exists", false, respondBoolean),
stat: createRequest(requestPath, "stat", false),
readdir: createRequest(requestPath, "readdir", false),
readfile: createRequest(requestPath, "readfile", false),
writefile: createRequest(requestFile, "writefile", true, respondNumber),
mkdir: createRequest(requestPath, "mkdir", true, respondBoolean),
unlink: createRequest(requestPath, "unlink", true, respondBoolean),
touch: createRequest(requestPath, "touch", true, respondBoolean),
search: createRequest(requestSearch, "search", false),
copy: createCrossRequest(requestCross, "copy"),
rename: createCrossRequest(requestCross, "rename"),
archive: createRequest(requestSelection, "archive", false),
};
};
/**
* Creates a new VFS Express router.
* @param {Core} core MeeseOS Core instance reference
* @returns {Object}
*/
module.exports = (core) => {
const router = express.Router();
const methods = vfs(core);
const middleware = createMiddleware(core);
const { useWebTokens, isAuthenticated } = core.make("meeseOS/express");
const vfsGroups = core.config("auth.vfsGroups", []);
const logEnabled = core.config("development");
// Middleware first
router.use(useWebTokens(core));
router.use(isAuthenticated(vfsGroups));
router.use(middleware);
// Then all VFS routes (needs implementation above)
router.get("/capabilities", wrapper(methods.capabilities));
router.get("/exists", wrapper(methods.exists));
router.get("/stat", wrapper(methods.stat));
router.get("/readdir", wrapper(methods.readdir));
router.get("/readfile", wrapper(methods.readfile));
router.post("/writefile", wrapper(methods.writefile));
router.post("/rename", wrapper(methods.rename));
router.post("/copy", wrapper(methods.copy));
router.post("/mkdir", wrapper(methods.mkdir));
router.post("/unlink", wrapper(methods.unlink));
router.post("/touch", wrapper(methods.touch));
router.post("/search", wrapper(methods.search));
router.post("/archive", wrapper(methods.archive));
// Finally catch promise exceptions
router.use((error, _req, res, _next) => {
const code = typeof error.code === "number"
? error.code
: errorCodes[error.code] || 400;
if (logEnabled) {
core.logger.error(error);
}
res.status(code).json({
error: error.toString(),
stack: logEnabled ? error.stack : undefined,
});
});
return { router, methods };
};