git.js

const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
const url = require('url');
const qs = require('querystring');
const httpDuplex = require('./http-duplex');

const { spawn } = require('child_process');
const { EventEmitter } = require('events');

const { parseGitName, createAction, infoResponse, onExit, basicAuth, noCache } = require('./util');

const services = ['upload-pack', 'receive-pack'];

/**
  * @event Git#push
  * @type {Object}
  * @property {HttpDuplex} push - is a http duplex object (see below) with these extra properties
  * @property {String} push.repo - the string that defines the repo
  * @property {String} push.commit - the string that defines the commit sha
  * @property {String} push.branch - the string that defines the branch
  * @example
    repos.on('push', function (push) { ... }

    Emitted when somebody does a `git push` to the repo.

    Exactly one listener must call `push.accept()` or `push.reject()`. If there are
    no listeners, `push.accept()` is called automatically.
  *
**/

/**
  * @event Git#tag
  * @type {Object}
  * @property {HttpDuplex} tag - an http duplex object (see below) with these extra properties:
  * @property {String} tag.repo - the string that defines the repo
  * @property {String} tag.commit - the string that defines the commit sha
  * @property {String} tag.version - the string that defines the repo
  * @example
    repos.on('tag', function (tag) { ... }

    Emitted when somebody does a `git push --tags` to the repo.
    Exactly one listener must call `tag.accept()` or `tag.reject()`. If there are
    No listeners, `tag.accept()` is called automatically.
  *
**/

/**
  * @event Git#fetch
  * @type {Object}
  * @property {HttpDuplex} fetch - an http duplex object (see below) with these extra properties:
  * @property {String} fetch.repo - the string that defines the repo
  * @property {String} fetch.commit - the string that defines the commit sha
  * @example
    repos.on('fetch', function (fetch) { ... }

    Emitted when somebody does a `git fetch` to the repo (which happens whenever you
    do a `git pull` or a `git clone`).

    Exactly one listener must call `fetch.accept()` or `fetch.reject()`. If there are
    no listeners, `fetch.accept()` is called automatically.
  *
*/

/**
  * @event Git#info
  * @type {Object}
  * @property {HttpDuplex} info - an http duplex object (see below) with these extra properties:
  * @property {String} info.repo - the string that defines the repo
  * @example
    repos.on('info', function (info) { ... }

    Emitted when the repo is queried for info before doing other commands.

    Exactly one listener must call `info.accept()` or `info.reject()`. If there are
    no listeners, `info.accept()` is called automatically.
  *
*/

/**
  * @event Git#info
  * @type {Object}
  * @property {HttpDuplex} info - an http duplex object (see below) with these extra properties:
  * @property {String} info.repo - the string that defines the repo
  * @example
    repos.on('info', function (info) { ... }

    Emitted when the repo is queried for info before doing other commands.

    Exactly one listener must call `info.accept()` or `info.reject()`. If there are
    no listeners, `info.accept()` is called automatically.
  *
*/

/**
  * @event Git#head
  * @type {Object}
  * @property {HttpDuplex} head - an http duplex object (see below) with these extra properties:
  * @property {String} head.repo - the string that defines the repo
  * @example
    repos.on('head', function (head) { ... }

    Emitted when the repo is queried for HEAD before doing other commands.

    Exactly one listener must call `head.accept()` or `head.reject()`. If there are
    no listeners, `head.accept()` is called automatically.
  *
*/

class Git extends EventEmitter {
  /**
   *
   * Handles invoking the git-*-pack binaries
   * @class Git
   * @extends EventEmitter
   * @param  {(String|Function)}    repoDir   - Create a new repository collection from the directory `repoDir`. `repoDir` should be entirely empty except for git repo directories. If `repoDir` is a function, `repoDir(repo)` will be used to dynamically resolve project directories. The return value of `repoDir(repo)` should be a string path specifying where to put the string `repo`. Make sure to return the same value for `repo` every time since `repoDir(repo)` will be called multiple times.
   * @param  {Object}    options - options that can be applied on the new instance being created
   * @param  {Boolean=}  options.autoCreate - By default, repository targets will be created if they don't exist. You can
   disable that behavior with `options.autoCreate = true`
   * @param  {Function}  options.authenticate - a function that has the following arguments (repo, username, password, next) and will be called when a request comes through if set
   *
     authenticate: (type, repo, username, password, next) => {
       console.log(type, repo, username, password);
       next();
     }
     // alternatively you can also pass authenticate a promise
     authenticate: (type, repo, username, password, next) => {
       console.log(type, repo, username, password);
       return new Promise((resolve, reject) => {
        if(username === 'foo') {
          return resolve();
        }
        return reject("sorry you don't have access to this content");
       });
     }
   * @param  {Boolean=}  options.checkout - If `opts.checkout` is true, create and expected checked-out repos instead of bare repos
  */
  constructor(repoDir, options={}) {
    super();

    if(typeof repoDir === 'function') {
        this.dirMap = repoDir;
    } else {
        this.dirMap = (dir) => {
            return (path.normalize(dir ? path.resolve(repoDir, dir) : repoDir));
        };
    }

    this.authenticate = options.authenticate;
    this.autoCreate = options.autoCreate === false ? false : true;
    this.checkout = options.checkout;
  }
  /**
   * Get a list of all the repositories
   * @method list
   * @memberof Git
   * @param  {Function} callback function to be called when repositories have been found `function(error, repos)`
   */
  list(callback) {
      fs.readdir(this.dirMap(), (error, results) => {
        if(error) return callback(error);
        let repos = results.filter((r) => {
          return r.substring(r.length - 3, r.length) == 'git';
        }, []);

        callback(null, repos);
      });
  }
  /**
   * Find out whether `repoName` exists in the callback `cb(exists)`.
   * @method exists
   * @memberof Git
   * @param  {String}   repo - name of the repo
   * @param  {Function=} callback - function to be called when finished
   */
  exists(repo, callback) {
      fs.exists(this.dirMap(repo), callback);
  }
  /**
   * Create a subdirectory `dir` in the repo dir with a callback `cb(err)`.
   * @method mkdir
   * @memberof Git
   * @param  {String}   dir - directory name
   * @param  {Function=} callback  - callback to be called when finished
   */
  mkdir(dir, callback) {
      // TODO: remove sync operations
      const parts = this.dirMap(dir).split(path.sep);
      for(var i = 0; i <= parts.length; i++) {
          const directory = parts.slice(0, i).join(path.sep);
          if(directory && !fs.existsSync(directory)) {
              fs.mkdirSync(directory);
          }
      }
      callback();
  }
  /**
   * Create a new bare repository `repoName` in the instance repository directory.
   * @method create
   * @memberof Git
   * @param  {String}   repo - the name of the repo
   * @param  {Function=} callback - Optionally get a callback `cb(err)` to be notified when the repository was created.
   */
  create(repo, callback) {
      var self = this;
      if (typeof callback !== 'function') callback = function () {};

      if (!/\.git$/.test(repo)) repo += '.git';

      self.exists(repo, function (ex) {
          if (!ex) {
              self.mkdir(repo, next);
          } else {
              next();
          }
      });

      function next (err) {
          if (err) return callback(err);

          var ps, error = '';

          var dir = self.dirMap(repo);
          if (self.checkout) {
              ps = spawn('git', [ 'init', dir ]);
          }
          else {
              ps = spawn('git', [ 'init', '--bare', dir ]);
          }

          ps.stderr.on('data', function (buf) { error += buf; });
          onExit(ps, function (code) {
              if (!callback) { return; }
              else if (code) callback(error || true);
              else callback(null);
          });
      }
  }
  /**
   * returns the typeof service being process
   * @method getType
   * @param  {String} service - the service type
   * @return {String}  - will respond with either upload or download
   */
  getType(service) {
    switch(service) {
      case 'upload-pack':
        return 'fetch';
      case 'receive-pack':
        return 'push';
      default:
        return 'unknown';
    }
  }
  /**
   * Handle incoming HTTP requests with a connect-style middleware
   * @method handle
   * @memberof Git
   * @param  {Object} req - http request object
   * @param  {Object} res - http response object
   */
  handle(req, res) {
      const handlers = [
        function(req, res) {
            if (req.method !== 'GET') return false;

            var self = this;
            var u = url.parse(req.url);
            var m = u.pathname.match(/\/(.+)\/info\/refs$/);
            if (!m) return false;
            if (/\.\./.test(m[1])) return false;

            var repo = m[1];
            var params = qs.parse(u.query);
            if (!params.service) {
                res.statusCode = 400;
                res.end('service parameter required');
                return;
            }

            var service = params.service.replace(/^git-/, '');
            if (services.indexOf(service) < 0) {
                res.statusCode = 405;
                res.end('service not available');
                return;
            }

            var repoName = parseGitName(m[1]);
            var next = (error) => {
              if(error) {
                res.setHeader("Content-Type", 'text/plain');
                res.setHeader("WWW-Authenticate", 'Basic realm="authorization needed"');
                res.writeHead(401);
                res.end(typeof error === 'string' ? error : error.toString());
                return;
              } else {
                return infoResponse(self, repo, service, req, res);
              }
            };

            // check if the repo is authenticated
            if(this.authenticate) {
                const type = this.getType(service);
                const promise = this.authenticate(type, repoName, basicAuth.bind(null, req, res), (error) => {
                  return next(error);
                });
                if(promise instanceof Promise) {
                  return promise
                    .then(next)
                    .catch(next);
                }
            } else {
              return next();
            }
        },
        function(req, res) {
            if (req.method !== 'GET') return false;

            var u = url.parse(req.url);
            var m = u.pathname.match(/^\/(.+)\/HEAD$/);
            if (!m) return false;
            if (/\.\./.test(m[1])) return false;

            var self = this;
            var repo = m[1];

            var next = () => {
                const file = self.dirMap(path.join(m[1], 'HEAD'));
                self.exists(file, (ex) => {
                    if (ex) fs.createReadStream(file).pipe(res);
                    else {
                        res.statusCode = 404;
                        res.end('not found');
                    }
                });
            };

            self.exists(repo, (ex) => {
                const anyListeners = self.listeners('head').length > 0;
                const dup = new httpDuplex(req, res);
                dup.exists = ex;
                dup.repo = repo;
                dup.cwd = self.dirMap(repo);

                dup.accept = dup.emit.bind(dup, 'accept');
                dup.reject = dup.emit.bind(dup, 'reject');

                dup.once('reject', (code) => {
                    dup.statusCode = code || 500;
                    dup.end();
                });

                if (!ex && self.autoCreate) {
                    dup.once('accept', (dir) => {
                        self.create(dir || repo, next);
                    });
                    self.emit('head', dup);
                    if (!anyListeners) dup.accept();
                } else if (!ex) {
                    res.statusCode = 404;
                    res.setHeader('content-type', 'text/plain');
                    res.end('repository not found');
                } else {
                    dup.once('accept', next);
                    self.emit('head', dup);
                    if (!anyListeners) dup.accept();
                }
            });
        },
        function(req, res) {
            if (req.method !== 'POST') return false;
            var m = req.url.match(/\/(.+)\/git-(.+)/);
            if (!m) return false;
            if (/\.\./.test(m[1])) return false;

            var self = this;
            var repo = m[1],
                service = m[2];

            if (services.indexOf(service) < 0) {
                res.statusCode = 405;
                res.end('service not available');
                return;
            }

            res.setHeader('content-type', 'application/x-git-' + service + '-result');
            noCache(res);

            var action = createAction({
                repo: repo,
                service: service,
                cwd: self.dirMap(repo)
            }, req, res);

            action.on('header', () => {
                var evName = action.evName;
                var anyListeners = self.listeners(evName).length > 0;
                self.emit(evName, action);
                if (!anyListeners) action.accept();
            });
        },
        (req, res) => {
            if (req.method !== 'GET' && req.method !== 'POST') {
                res.statusCode = 405;
                res.end('method not supported');
            } else {
                return false;
            }
        },
        (req, res) => {
            res.statusCode = 404;
            res.end('not found');
        }
      ];
      res.setHeader('connection', 'close');
      var self = this;
      (function next(ix) {
          var done = () => {
              next(ix + 1);
          };
          var x = handlers[ix].call(self, req, res, done);
          if (x === false) next(ix + 1);
      })(0);
  }
  /**
   * starts a git server on the given port
   * @method listen
   * @memberof Git
   * @param  {Number}   port     - the port to start the server on
   * @param  {Object=}   options  - the options to add extended functionality to the server
   * @param  {String=}   options.type - this is either https or http (the default is http)
   * @param  {Buffer|String=}   options.key - the key file for the https server
   * @param  {Buffer|String=}   options.cert - the cert file for the https server
   * @param  {Function} callback - the function to call when server is started or error has occured
   */
  listen(port, options, callback) {
      const self = this;
      if(typeof options == 'function' || !options) {
        callback = options;
        options = { type: 'http' };
      }
      const createServer = options.type == 'http' ? http.createServer : https.createServer.bind(this, options);

      this.server = createServer(function(req, res) {
          self.handle(req, res);
      });

      this.server.listen(port, callback);
  }
  /**
   * closes the server instance
   * @method close
   * @memberof Git
   */
  close() {
      this.server.close();
  }
}

module.exports = Git;