Home Reference Source

src/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 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 : !!result;
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
 */
const parseRangeHeader = (range, size) => {
  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
 */
const onDone = (req, res) => {
  if (req.files) {
    for (let 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
 */
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
 */
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 && req.headers.range;
  const session = {...req.session || {}};
  let result = options || {};

  if (typeof options === 'string') {
    try {
      result = JSON.parse(req.fields.options) || {};
    } catch (e) {
      // Allow to fall through
    }
  }

  if (range) {
    result.range = parseRangeHeader(req.headers.range);
  }

  return {
    ...result,
    session
  };
};

// Standard request with only a target
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, {
              'Content-Range': `bytes ${start}-${realEnd}/${size}`,
              'Accept-Ranges': 'bytes',
              'Content-Length': chunksize,
              '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
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 !!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 !!result;
};

/*
 * VFS Methods
 */
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')
  };
};

/*
 * Creates a new VFS Express router
 */
module.exports = core => {
  const router = express.Router();
  const methods = vfs(core);
  const middleware = createMiddleware(core);
  const {isAuthenticated} = core.make('osjs/express');
  const vfsGroups = core.config('auth.vfsGroups', []);
  const logEnabled = core.config('development');

  // Middleware first
  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));

  // Finally catch promise exceptions
  router.use((error, req, res, next) => {
    // TODO: Better error messages
    const code = typeof error.code === 'number'
      ? error.code
      : (errorCodes[error.code] || 400);

    if (logEnabled) {
      console.error(error);
    }

    res.status(code)
      .json({
        error: error.toString(),
        stack: logEnabled ? error.stack : undefined
      });
  });

  return {router, methods};
};