/*
 * config-router.js
 *
 * Copyright (C) 2009-13 by RStudio, Inc.
 *
 * This program is licensed to you under the terms of version 3 of the
 * GNU Affero General Public License. This program is distributed WITHOUT
 * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
 * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
 * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
 *
 */
// TODO: Drop root whenever possible
const path = require('path');
const util = require('util');
const stable = require('stable');
const assert = require('assert');
const _ = require('underscore');
const iputil = require('../core/iputil');
const map = require('../core/map');
const paths = require('../core/paths');
const permissions = require('../core/permissions');
const qutil = require('../core/qutil');
const router = require('./router');
const UserDirsRouter = require('./user-dirs-router');
const config = require('../config/config');
const posix = require('../../build/Release/posix');
const DirectoryRouter = require('./directory-router');
const throwForNode = require('../config/schema').throwForNode;
const configRouterUtil = require('./config-router-util');

exports.createRouter_p = createRouter_p;
function createRouter_p(configPath, schedulerRegistry) {
  return config.read_p(
    configPath, 
    paths.projectFile('config/shiny-server-rules.config'))
  .then(function(conf) {
    checkPermissions(conf);
    return new ConfigRouter(conf, schedulerRegistry);
  });
}

function checkPermissions(conf) {
  // run_as nodes that can't be obeyed due to permissions
  var users = _.filter(conf.search('run_as', false), function(node) {
    return _.some(node.values.users, function(user){
      return !permissions.canRunAs(user);
    });
  });

  // user_apps nodes (the mere presence of these means root is needed)
  var userapps = conf.search('user_apps', false);
  var userdirs = conf.search('user_dirs', false);

  // Listen nodes with ports under 1024
  var listens = _.filter(conf.search('listen', false), function(node) {
    return node.values.port < 1024;
  });

  // Array of strings representing unique users to run_as
  var uniqueUsers = _.chain(conf.search('run_as', false))
      .map(function(node) {
        return node.values.user;
      })
      .uniq()
      .value();

  if (permissions.isSuperuser()) {
    // Check if superuser is actually necessary
    if (uniqueUsers.length == 1 && (userapps.length + userdirs.length == 0)&& !listens.length) {
      logger.warn('Running as root unnecessarily is a security risk! You could be running more securely as non-root.');
    }

    return;
  }

  // If we got here, we're not running as root

  if (users.length) {
    var userLine = users[0].values.users;
    throwForNode(users[0],
        new Error("The user '" + permissions.getProcessUser() + "' does not have permissions to run applications as one of the users in '" + 
          userLine.join(',') + 
          "'. Please restart shiny-server as one of the users in  '" + 
          userLine.join(',') + "'."));
  }

  if (userapps.length) {
    throwForNode(userapps[0], 
        new Error('shiny-server must be run as root to use the user_apps directive'));
  }
  if (userdirs.length) {
    throwForNode(userdirs[0], 
        new Error('shiny-server must be run as root to use the user_dirs directive'));
  }

  if (listens.length) {
    var port = listens[0].values.port;
    throwForNode(listens[0],
        new Error("The user '" + permissions.getProcessUser() + "' does not have permission to listen on port " + port + ". Please choose a port number of 1024 or above, or restart shiny-server as root."));
  }
}

function ConfigRouter(conf, schedulerRegistry) {
  this.servers = createServers(conf);
  this.accessLogSpec = createAccessLogSpec(conf.getOne('access_log'));
  this.socketDir = conf.getValues('socket_dir').path;

  this.$allowAppOverride = conf.getValues('allow_app_override').enabled;
  this.$templateDir = conf.getValues('template_dir').dir;

  this.httpAllowCompression = true;
  if (conf.getOne('http_allow_compression')) {
    this.httpAllowCompression = conf.getValues('http_allow_compression').enabled;
  }

  this.httpKeepaliveTimeout = 45 * 1000;
  if (conf.getOne('http_keepalive_timeout')) {
    this.httpKeepaliveTimeout = conf.getValues('http_keepalive_timeout').timeout * 1000;
  }

  this.sockjsHeartbeatDelay = 25 * 1000;
  if (conf.getOne('sockjs_heartbeat_delay')) {
    this.sockjsHeartbeatDelay = conf.getValues('sockjs_heartbeat_delay').delay * 1000;
  }

  this.sockjsDisconnectDelay = 5 * 1000;
  if (conf.getOne('sockjs_disconnect_delay')) {
    this.sockjsDisconnectDelay = conf.getValues('sockjs_disconnect_delay').delay * 1000;
  }

  var apps = conf.search("application", true);
  if (apps && apps.length > 0){
    logger.error("The `application` configuration has been deprecated. Please "+ 
      "see http://rstudio.github.io/shiny-server/latest/#location for more " +
      "information on how to adjust your configuration file.");
    process.exit(1);
  }
}
Object.defineProperty(ConfigRouter.prototype, "reconnect", {
  get: function() { throw new Error("reconnect property not supported"); }
});
(function() {
  this.getAppSpec_p = function(req, res) {
    var self = this;
    var scored = _.map(this.servers, function(server) {
      return {
        server: server,
        score: server.getScore(req, res)
      };
    });

    // Ignore servers that don't match
    scored = _.filter(scored, function(serverWithScore) {
      return serverWithScore.score > 0;
    });

    // Stable sort the scored servers; higher scores at the beginning
    scored = stable(scored, function(a, b) {
      return a.score < b.score
    });

    // Return a promise that resolves to serially calling each server's
    // getAppSpec_p and returning the first non-falsy result
    return router.getFirstAppSpec_p(_.pluck(scored, 'server'), req, res)
    .then(function(appSpec){
      if (!appSpec || !appSpec.appDir){
        //occurs when no router is found to handle the request
        return appSpec;
      }
      return appSpec;
    })
    
  };

  this.getAddresses = function() {
    return _.map(this.servers, function(server) {
      return { address: server.host, port: server.port };
    });
  };

  this.getAppOverride = function(){
    return this.$allowAppOverride;
  }
}).call(ConfigRouter.prototype);

/**
 * Crawl the parsed and validated config file data to create an array of
 * ServerRouters.
 */
function createServers(conf) {
  var seenKeys = map.create();

  var servers = _.map(conf.search('server'), function(serverNode) {
    var listenNode = serverNode.getOne('listen');
    if (!listenNode)
      throwForNode(serverNode,
          new Error('Required "listen" directive is missing'));

    var port = serverNode.getValues('listen').port;
    if (typeof port === 'undefined')
      port = 80;
    if (port <= 0)
      throwForNode(listenNode, new Error('Invalid port number'));

    var host = serverNode.getValues('listen').host || '::';
    if (host === '*')
      host = '::';

    if (!iputil.isValid(host)) {
      throwForNode(listenNode,
          new Error('Invalid IP address "' + host + '"'));
    }

    if (iputil.hasZone(host)) {
      throwForNode(listenNode,
          new Error(`Invalid IP address "${host}"--zone IDs are not currently supported`))
    }

    var serverNames = serverNode.getValues('server_name').names;

    // Read all locations. Use post-order traversal so that nested locs
    // get priority over their parents.
    var locations = _.chain(serverNode.search('location', false, true))
    .map(createLocation)
    .compact()
    .value();

    // We get the templateDir at the server level so that a global or server-
    // wide templateDir can be attached to the request directly. We may need
    // this if a page gets returns without a matching AppSpec (like a 404).
    var templateDir = serverNode.getValues('template_dir').dir;

    _.each(serverNames || [''], function(serverName) {
      var key = host + ':' + port;
      if (serverName !== '')
        key += '(' + serverName + ')';
      if (seenKeys[key])
        throwForNode(listenNode,
            new Error(key + ' combination conflicts with earlier server definition'));
      seenKeys[key] = true;
    });

    return new ServerRouter(port, host, serverNames, locations, templateDir);
  });

  return servers;
}

function createAccessLogSpec(accessLogNode) {
  if (!accessLogNode)
    return null;
  return {
    path: accessLogNode.values.path,
    format: accessLogNode.values.format
  };
}

/**
 * Delegates requests to sub-routers (locations). Can indicate how well its
 * configuration matches up to a request (`getScore`).
 */
function ServerRouter(port, host, vhosts, locations, templateDir) {
  assert(port);
  assert(host);
  this.port = port;
  this.host = host;
  this.vhosts = _.invoke(vhosts || [], 'toLowerCase');
  this.$locations = locations;
  this.$templateDir = templateDir;
}
(function() {
  /**
   * This score helps determine the order in which servers will be tried for
   * a given request. Higher scores are tried first, and if two servers have
   * the same score then they are tried in the order they are found in the
   * configuration file.
   *
   * A score of 0 or lower means failure--the server is not able to handle
   * the request.
   *
   * - Matching local port number: 0 points
   * - Non-matching local port number: fail
   *
   * - Server has wildcard (*, ::, or 0.0.0.0) for host: 1 point
   * - Server has same local IP address as request: 2 points
   * - Non-wildcard host server and differing request host: fail
   *
   * - Server has no virtual host: 0 points
   * - Server has same virtual host: 3 points
   * - Server has differing virtual host: fail
   */
  this.getScore = function(req, res) {
    var score = 0;

    if (!req.address && !req.connection) {
      logger.warn('Request with no address and no connection: ' + util.inspect(req));
      return 0;
    }

    var address = req.address || req.connection.address();
    var port = address.port;
    var host = address.address;
    var vhost = (req.headers.host || '').split(/:/)[0].toLowerCase();
    if (!req.headers.host) {
      logger.warn('No host header sent by user-agent ' + req.headers['user-agent']);
    }
    if (port != this.port)
      return 0;
    
    if (iputil.isWildcard(this.host))
      score += 1;
    else if (iputil.equal(this.host, host))
      score += 2;
    else
      return 0;

    if (this.vhosts.length > 0) {
      // TODO: Support non-literal server_name values
      // http://nginx.org/en/docs/http/ngx_http_core_module.html#server_name
      var found = _.find(this.vhosts, function(vh) {
        return vh === vhost;
      });
      if (found) {
        score += 3;
      } else {
        return 0;
      }
    }

    assert(score > 0);
    return score;
  };

  this.getAppSpec_p = function(req, res) {
    assert(this.getScore(req, res) > 0);

    // Attach the templateDir directly to the request so that we can properly
    // generate pages without needing an AppSpec.
    req.templateDir = this.$templateDir;

    // Return a promise that resolves to serially calling each location's
    // getAppSpec_p and returning the first non-falsy result
    return router.getFirstAppSpec_p(this.$locations, req, res);
  };
}).call(ServerRouter.prototype);

function joinUrlParts(a, b) {
  return a.replace(/\/$/, '') + '/' + b.replace(/^\//, '');
}

function deriveLocationPath(locNode) {
  var path = locNode.values.path;
  while (locNode.parent && locNode.parent.name === 'location') {
    locNode = locNode.parent;
    path = joinUrlParts(locNode.values.path, path);
  }
  if (!/^\//.test(path))
    path = '/' + path;
  return path;
}

function createSiteDir(locNode, node, settings) {
  var locPath = deriveLocationPath(locNode);
  var rootPath = deriveLocationPath(node.parent);

  var sitedirPath = locNode.getValues('site_dir').rootPath;

  var runas = locNode.getValues('run_as').users;
  if (!runas || runas.length == 0)
    throwForNode(locNode, new Error(
        'Required "run_as" directive not present or has no users.'));

  var logdir = locNode.getValues('log_dir').path;
  if (!logdir)
    throwForNode(locNode, new Error(
        'Required "log_dir" directive was not found'));

  var dirIndex = !!locNode.getValues('directory_index').enabled;
  // TODO: Filter on locPath
  var realRouter = new DirectoryRouter(sitedirPath, runas, dirIndex, rootPath,
      logdir, settings);
  if (locPath !== rootPath) {
    return new router.PrefixFilterRouter(realRouter, locPath);
  } else {
    return realRouter;
  }
}

function createAppDir(locNode, node, settings) {
  if (locNode.depth !== (node.depth - 1)) {
    throwForNode(locNode, new Error('A location node may not inherit the app_dir directive.'));
  }

  var locPath = deriveLocationPath(locNode);
  var appdirPath = locNode.getValues('app_dir').path;

  var runas = locNode.getValues('run_as').users;
  if (!runas || runas.length == 0)
    throwForNode(locNode, new Error(
        'Required "run_as" directive not present or has no users.'));

  var logdir = locNode.getValues('log_dir').path;
  if (!logdir)
    throwForNode(locNode, new Error(
        'Required "log_dir" directive was not found'));

  assert(appdirPath);
  return new router.SingleAppRouter(
      appdirPath, runas, locPath, logdir, settings);
}

/**
 * @param userDirsMode A Boolean representing whether we should be operating as
 *   'user_apps' (which ignores the run_as directive), or 'user_dirs' (which 
 *   respects the run_as directive).
 **/
function createUserApps(locNode, node, settings, userDirsMode) {
  var locPath = deriveLocationPath(locNode);
  var rootPath = deriveLocationPath(node.parent);

  var userappsNode = locNode.getOne('user_apps');
  var userappsEnabled = node.values.length == 0 || node.values.enabled;
  if (!userappsEnabled) {
    throwForNode(node, new Error('"' + node.args[0] + '" can no longer be used with the user_apps directive. Please omit the user_apps directive instead.'));
  }

  // We initially didn't require that user_apps would have a run_as setting
  // associated with it. So to maintain backwards compatibility, we need to 
  // continue to maintain these different code paths, at least for the time 
  // being. For now, we'll pass the (optional) run_as parameter into the 
  // construction of the UserDirsRouter.
  var runas = null;
  if (userDirsMode)
    runas = locNode.getValues('run_as').users;

  var groupsNode = locNode.getOne('members_of');
  var groups = locNode.getValues('members_of').groups || [];
  // groups is a list of group names; map it to a list of numeric group IDs
  groups = _.map(groups, function(group) {
    try {
      var groupInfo = posix.getgrnam(group);
      if (!groupInfo) {
        throwForNode(groupsNode,
            new Error('Group "' + group + '" does not exist'));
      }
    } catch (ex) {
      throwForNode(groupsNode, ex);
    }
    return groupInfo.gid;
  });

  var dirIndex = !!locNode.getValues('directory_index').enabled;

  var realRouter = new UserDirsRouter(rootPath, groups, settings, runas, 
    dirIndex);
  if (locPath !== rootPath) {
    return new router.PrefixFilterRouter(realRouter, locPath);
  } else {
    return realRouter;
  }
}

function createRedirect(locNode, node, settings) {
  var locPath = deriveLocationPath(locNode);
  var rootPath = deriveLocationPath(node.parent);

  var redirectArgs = node.values;
  var realRouter = new router.RedirectRouter(
      rootPath, redirectArgs.url, redirectArgs.statusCode, redirectArgs.exact);
  if (locPath !== rootPath) {
    return new router.PrefixFilterRouter(realRouter, locPath);
  } else {
    return realRouter;
  }
}

/**
 * Validates the location node, and returns the appropriate type of router
 * it represents.
 */
function createLocation(locNode) {
  // TODO: Include ancestor locations in path
  var path = locNode.values.path;

  var terminalLocation = !locNode.getOne('location', false);
  var node = locNode.getOne(/^site_dir|user_dirs|app_dir|user_apps|redirect$/);
  if (!node) {
    // No directives. Only allow this if child locations exist.
    if (terminalLocation)
      throwForNode(locNode, new Error('location directive must contain (or inherit) one of site_dir, user_apps, app_dir, or redirect'));
    else
      return null;
  }

  var settings = map.create();
  var gaid = locNode.getValues('google_analytics_id').gaid;
  if (gaid)
    settings.gaTrackingId = gaid;

  var logAsUser = locNode.getValues('log_as_user').enabled;
  settings.logAsUser = logAsUser;

  // Add the templateDir to the AppSpec, if we have one.
  var templateDir = locNode.getValues('template_dir').dir;
  if (templateDir){
    settings.templateDir = templateDir;
  }

  settings = configRouterUtil.parseApplication(settings, 
      locNode, true);

  switch (node.name) {
    case 'site_dir':
      return createSiteDir(locNode, node, settings);
    case 'app_dir':
      return createAppDir(locNode, node, settings);
    case 'user_apps':
      return createUserApps(locNode, node, settings);
    case 'user_dirs':
      return createUserApps(locNode, node, settings, true);
    case 'redirect':
      return createRedirect(locNode, node, settings);
    default:
      throwForNode(locNode, new Error('Node name ' + node.name + ' was not expected here'));
  }
}
