unifile-webdav.js

'use strict';

const Url = require('url');
const request = require('request');
const Promise = require('bluebird');
const XmlStream = require('xml-stream');

const Tools = require('./tools');

const NAME = 'webdav';

const callAPI = Symbol('callAPI');

function toFileInfos(stat) {

  let name = decodeURI(stat['d:href']);
  const isDir = name.endsWith('/');
  name = isDir ? name.slice(0, -1) : name;
  name = name.split('/').pop();

  return {
    size: stat['d:propstat']['d:prop']['d:getcontentlength'] || 'n/a',
    modified: new Date(stat['d:propstat']['d:prop']['d:getlastmodified']),
    name: name,
    isDir: isDir,
    mime: isDir ? 'application/directory' : stat['d:propstat']['d:prop']['d:getcontenttype']
  };
}

/**
 * Service connector for {@link https://en.wikipedia.org/wiki/WebDAV|WebDAV} server.
 */
class WebDavConnector {
  /**
   * @constructor
   * @param {Object} config - Configuration object
   * @param {string} config.redirectUri - URI of the login page
   * @param {ConnectorStaticInfos} [config.infos] - Connector infos to override
   */
  constructor(config) {
    this.config = config;
    this.infos = Tools.mergeInfos(config.infos, {
      name: NAME,
      displayName: 'WebDAV',
      icon: '../assets/webdav.png',
      description: 'Edit files on a WebDAV server'
    });
    this.name = this.infos.name;
  }

  getInfos(session) {
    return Object.assign({
      isLoggedIn: (session && 'token' in session),
      isOAuth: false,
      username: session.user || 'Unauthentified'
    }, this.infos);
  }

  getAuthorizeURL(session) {
    return Promise.resolve(this.config.redirectUri);
  }

  setAccessToken(session, token) {
    session.token = token;
    return Promise.resolve(token);
  }

  clearAccessToken(session) {
    session.token = null;
    return Promise.resolve();
  }

  login(session, loginInfos) {
    if(loginInfos.constructor === String) {
      session.url = Url.parse(loginInfos);
      [session.user, session.password] = session.url.auth.split(':');
    } else {
      session.url = Url.parse(loginInfos.host);
      Object.assign(session, loginInfos);
    }
    return Promise.resolve(this.setAccessToken(session, session.user));
  }

  // Filesystem commands

  readdir(session, path) {
    return this[callAPI](session, path, {}, 'PROPFIND', false, {
      Depth: 1
    })
    .then((list) => {
      return list.reduce((memo, entry, index) => {
        // Don't return the first element as it's '.'
        if(index == 0) return memo;

        memo.push(toFileInfos(entry));
        return memo;
      }, []);
    });
  }

  stat(session, path) {
    return this[callAPI](session, path, {}, 'PROPFIND', false, {
      Depth: 0
    })
    .then((entries) => {
      return toFileInfos(entries[0]);
    });
  }

  mkdir(session, path) {
    return this[callAPI](session, path, {}, 'MKCOL');
  }

  writeFile(session, path, data) {
    return this[callAPI](session, path, data, 'PUT');
  }

  createWriteStream(session, path) {
    return this[callAPI](session, path, {}, 'PUT', true);
  }

  readFile(session, path) {
    return this[callAPI](session, path, {}, 'GET');
  }

  createReadStream(session, path) {
    return this[callAPI](session, path, {}, 'GET', true);
  }

  rename(session, src, dest) {
    return this[callAPI](session, src, {}, 'MOVE', false, {
      'Destination': session.url.href + dest
    });
  }

  unlink(session, path) {
    return this[callAPI](session, path, {}, 'DELETE');
  }

  rmdir(session, path) {
    return this.unlink(session, path);
  }

  batch(session, actions, message) {
    return Promise.each(actions, (action) => {
      const act = action.name.toLowerCase();
      switch (act) {
        case 'unlink':
        case 'rmdir':
        case 'mkdir':
          this[act](session, action.path);
          break;
        case 'rename':
          this[act](session, action.path, action.destination);
          break;
        case 'writefile':
          this.writeFile(session, action.path, action.content);
          break;
        default:
          console.warn(`Unsupported batch action: ${action.name}`);
      }
    });
  }

  /**
   * Make a call to the WebDAV server
   * @param {Object} session - WebDAV session storage
   * @param {string} path - End point path
   * @param {Object} data - Data to pass. Will be ignored if method is GET
   * @param {string} method - HTTP verb to use
   * @param {boolean} [isStream=false] - Use the API as a stream
   * @param {Object} [headers={}] - Additionals headers to send
   * @return {Promise|Stream} a Promise of the result send by server or a stream to the endpoint
   * @private
   */
  [callAPI](session, path, data, method, isStream = false, headers = {}) {
    const opts = {
      url: session.url.href + path,
      method: method,
      auth: {
        user: session.user,
        password: session.password
      },
      headers: headers
    };


    if(isStream) return request(opts);
    else {
      if(Object.keys(data).length !== 0) opts.body = data;
      return new Promise((resolve, reject) => {
        const req = request(opts);
        req.on('response', (res) => {
          if(res.statusCode >= 400) {
            const error = new Error(res.statusMessage);
            error.statusCode = res.statusCode;
            res.pipe(process.stdout);
            return reject(error);
          }

          if(res.headers['content-type'].includes('application/xml')) {
            const results = [];
            const resStream = new XmlStream(req);
            resStream.on('endElement: d:response', (item) => {
              results.push(item);
            });
            resStream.on('end', () => {
              return resolve(results);
            });
            resStream.on('error', (err) => {
              return reject(err);
            });
          } else {
            const encoding = (res.headers['content-type'].match(/charset=(\S+)/) || ['utf8']).pop();
            const chunks = [];
            res.on('data', (chunk) => chunks.push(chunk));
            res.on('end', () => {
              return resolve(Buffer.concat(chunks).toString(encoding));
            });
          }
        });
      });
    }
  }
}

module.exports = WebDavConnector;