Home Reference Source

src/filesystem.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>
 * @license Simplified BSD License
 */

import * as VFS from './vfs';
import {EventEmitter} from '@osjs/event-emitter';
import {parseMountpointPrefix, filterMountByGroups, createWatchEvents} from './utils/vfs';
import defaultAdapter from './adapters/vfs/null';
import systemAdapter from './adapters/vfs/system';
import appsAdapter from './adapters/vfs/apps';
import logger from './logger';
import merge from 'deepmerge';

/**
 * VFS Mountpoint attributes
 *
 * @typedef {Object} FilesystemMountpointAttributes
 * @property {string} [visibility='global'] Visibility in UI
 * @property {boolean} [local=true] Local filesystem ?
 * @property {boolean} [searchable=true] If can be searched
 * @property {boolean} [readOnly=false] Readonly
 */

/**
 * VFS Mountpoint
 *
 * @typedef {Object} FilesystemMountpoint
 * @property {string} name Name
 * @property {string} label Label
 * @property {string} adapter Adater name
 * @property {string} [root] System adapter root
 * @property {boolean} [enabled=true] Enabled state
 * @property {FilesystemMountpointAttributes} [attributes] Attributes
 */

/**
 * Filesystem Adapter Methods
 * TODO: typedef
 * @typedef {Object} FilesystemAdapterMethods
 * @property {Function} capabilities
 * @property {Function} readdir
 * @property {Function} readfile
 * @property {Function} writefile
 * @property {Function} copy
 * @property {Function} move
 * @property {Function} rename
 * @property {Function} mkdir
 * @property {Function} unlink
 * @property {Function} exists
 * @property {Function} stat
 * @property {Function} url
 * @property {Function} download
 * @property {Function} search
 * @property {Function} touch
 */

/**
 * @callback FilesystemAdapterWrapper
 * @return {FilesystemAdapterMethods}
 */

/**
 * Filesystem Options
 *
 * @typedef {Object} FilesystemOptions
 * @property {{name: FilesystemAdapterWrapper}} [adapters] Adapter registry
 * @property {FilesystemMountpoint[]} [mounts] Mountpoints
 */

/**
 * Filesystem Class that manages filesystems and adapters
 */
export default class Filesystem extends EventEmitter {

  /**
   * Create filesystem manager
   *
   * @param {Core} core Core reference
   * @param {FilesystemOptions} [options] Options
   */
  constructor(core, options = {}) {
    options = {
      adapters: {},
      mounts: [],
      ...options
    };

    super('Filesystem');

    /**
     * Core instance reference
     * @type {Core}
     * @readonly
     */
    this.core = core;

    /**
     * Adapter registry
     * @type {{name: FilesystemAdapterWrapper}}
     * @readonly
     */
    this.adapters = {
      system: systemAdapter,
      apps: appsAdapter,
      ...this.core.config('vfs.adapters', {}),
      ...options.adapters
    };

    /**
     * Mountpoints
     * @type {FilesystemMountpoint[]}
     */
    this.mounts = [];

    /**
     * Options
     * @type {FilesystemOptions}
     */
    this.options = {};

    /**
     * A wrapper for VFS method requests
     * @type {{key: Function}}
     * @readonly
     */
    this.proxy = Object.keys(VFS).reduce((result, method) => {
      return {
        [method]: (...args) => this._request(method, ...args),
        ...result
      };
    }, {});
  }

  /**
   * Mounts all configured mountpoints
   * @param {boolean} [stopOnError=true] Stop on first error
   * @return {Promise<boolean[]>}
   */
  mountAll(stopOnError = true) {
    this.mounts = this._getConfiguredMountpoints();

    const fn = m => stopOnError
      ? this._mountpointAction(m)
      : this._mountpointAction(m).catch(err => logger.warn('Error while mounting', m, err));

    return Promise.all(this.mounts.map(fn));
  }

  /**
   * Adds a new mountpoint
   * @param {FilesystemMountpoint} props Mountpoint props
   * @param {boolean} [automount=true] Automount after creation
   * @return {Promise<boolean>}
   */
  addMountpoint(props, automount = true) {
    const mount = this.createMountpoint(props);

    this.mounts.push(mount);

    if (automount) {
      return this.mount(mount.name);
    }

    return Promise.resolve(true);
  }

  /**
   * Mount given mountpoint
   * @param {string|FilesystemMountpoint} m Mountpoint name or object
   * @throws {Error} On invalid name or if already mounted
   * @return {Promise<boolean>}
   */
  mount(m) {
    if (typeof m === 'string') {
      return this._mountAction(m, false);
    }

    return this.addMountpoint(m);
  }

  /**
   * Unmount given filesystem
   * @param {string} name Filesystem name
   * @throws {Error} On invalid name or if already unmounted
   * @return {Promise<boolean>}
   */
  unmount(name) {
    return this._mountAction(name, true);
  }

  /**
   * Internal wrapper for mounting/unmounting
   *
   * @private
   * @param {FilesystemMountpoint} mountpoint The mountpoint
   * @param {boolean} [unmount=false] If action is unmounting
   * @return {Promise<boolean>}
   */
  _mountpointAction(mountpoint, unmount = false) {
    const eventName = unmount ? 'unmounted' : 'mounted';
    const coreEventName = unmount ? 'unmount' : 'mount';

    return mountpoint._adapter[coreEventName]({}, mountpoint)
      .then(result => {
        if (result) {
          mountpoint.mounted = !unmount;

          this.emit(eventName, mountpoint);
          this.core.emit('osjs/fs:' + coreEventName);
        }

        return result;
      });
  }

  /**
   * Internal wrapper for mounting/unmounting by name
   *
   * @private
   * @param {string} name Mountpoint name
   * @param {boolean} [unmount=false] If action is unmounting
   * @return {Promise<boolean>}
   */
  _mountAction(name, unmount) {
    return Promise.resolve(this.mounts.find(m => m.name === name))
      .then(found => {
        const _ = this.core.make('osjs/locale').translate;

        // FIXME: Add already mounting state
        if (!found) {
          throw new Error(_('ERR_VFS_MOUNT_NOT_FOUND', name));
        } else if (unmount && !found.mounted) {
          throw new Error(_('ERR_VFS_MOUNT_NOT_MOUNTED', name));
        } else if (!unmount && found.mounted) {
          throw new Error(_('ERR_VFS_MOUNT_ALREADY_MOUNTED', name));
        }

        return this._mountpointAction(found, unmount);
      });
  }

  /**
   * Gets the proxy for VFS methods
   * FIXME: Not correct type, but works for documentation atm
   * @return {FilesystemAdapterMethods} A map of VFS functions
   */
  request() {
    return this.proxy;
  }

  /**
   * Perform a VFS method request
   *
   * @private
   * @param {string} method VFS method name
   * @param {*} ...args Arguments
   * @return {*}
   */
  _request(method, ...args) {
    const ev = `osjs/vfs:${method}`;

    const done = (error) => {
      this.core.emit(`${ev}:done`, ...args);

      if (!error && this.core.config('vfs.watch')) {
        const eva = createWatchEvents(method, args);
        eva.forEach(([e, a]) => this.core.emit(e, a));
      }
    };

    this.core.emit(ev, ...args);

    return this._requestAction(method, ...args)
      .then(result => {
        done();
        return result;
      })
      .catch(error => {
        done(error);
        throw error;
      });
  }

  /**
   * Request action wrapper
   * @private
   * @param {string} method
   * @param {*} ...args Arguments
   * @return {Promise<*>}
   */
  _requestAction(method, ...args) {
    if (['rename', 'move', 'copy'].indexOf(method) !== -1) {
      const [src, dest] = args;
      const srcMount = this.getMountpointFromPath(src);
      const destMount = this.getMountpointFromPath(dest);
      const sameAdapter = srcMount.adapter === destMount.adapter;

      if (!sameAdapter) {
        return VFS.readfile(srcMount._adapter, srcMount)(src)
          .then(ab => VFS.writefile(destMount._adapter, destMount)(dest, ab))
          .then(result => {
            return method === 'rename'
              ? VFS.unlink(srcMount._adapter, srcMount)(src).then(() => result)
              : result;
          });
      }
    }

    const [file] = args;
    const mount = this.getMountpointFromPath(file);

    return VFS[method](mount._adapter, mount)(...args);
  }

  /**
   * Creates a new mountpoint based on given properties
   * @param {FilesystemMountpoint} props Properties
   * @return {FilesystemMountpoint}
   */
  createMountpoint(props) {
    const name = props.adapter || this.core.config('vfs.defaultAdapter');
    const adapter = {...defaultAdapter, ...this.adapters[name](this.core)};

    const result = merge({
      enabled: true,
      mounted: false,
      adapter: name,
      attributes: {
        visibility: 'global',
        local: true,
        searchable: true,
        readOnly: false
      }
    }, props);

    return {
      _adapter: adapter,
      label: name,
      root: `${result.name || name}:/`,
      ...result
    };
  }

  /**
   * Gets mountpoint from given path
   * @param {string|VFSFile} file The file
   * @return {FilesystemMountpoint|null}
   */
  getMountpointFromPath(file) {
    const path = typeof file === 'string' ? file : file.path;
    const prefix = parseMountpointPrefix(path);
    const _ = this.core.make('osjs/locale').translate;

    if (!prefix) {
      throw new Error(_('ERR_VFS_PATH_FORMAT_INVALID', path));
    }

    const found = this.mounts.find(m => m.name === prefix);

    if (!found) {
      throw new Error(_('ERR_VFS_MOUNT_NOT_FOUND_FOR', `${prefix}:`));
    }

    return found;
  }

  /**
   * Gets all mountpoints
   * @return {FilesystemMountpoint[]}
   */
  getMounts(all = false) {
    const user = this.core.getUser();
    const theme = this.core.make('osjs/theme');
    const icon = str => str
      ? (typeof str === 'string' ? str : theme.icon(str.name))
      : theme.icon('drive-harddisk');

    return this.mounts
      .filter(m => all || m.mounted)
      .filter(m => m.enabled !== false)
      .filter(m => {
        const mg = m.attributes ? m.attributes.groups : [];
        const ms = m.attributes ? m.attributes.strictGroups !== false : true;
        return filterMountByGroups(user.groups)(mg, ms);
      })
      .map(m => ({
        attributes: {...m.attributes},
        icon: icon(m.icon),
        name: m.name,
        label: m.label,
        root: m.root
      }));
  }

  /**
   * Gets configured mountpoints
   * @return {FilesystemMountpoint[]}
   */
  _getConfiguredMountpoints() {
    const list = [
      ...this.core.config('vfs.mountpoints', []),
      ...(this.options.mounts || [])
    ];

    return list
      .map(mount => {
        try {
          return this.createMountpoint(mount);
        } catch (e) {
          logger.warn('Error while creating mountpoint', e);
        }

        return null;
      })
      .filter((mount, pos, arr) => {
        const index = arr.findIndex(item => item.label === mount.label || item.root === mount.label);
        if (index === pos) {
          return true;
        }

        logger.warn('Removed duplicate mountpoint', mount);
        return false;
      })
      .filter(mount => mount !== null);
  }
}