qwgit / lib / repos.js
http{/,s} git server
git clone http://git.nthia.dev/qwgit

const path = require('path')
const { nextTick } = process
const { randomBytes } = require('crypto')
const { spawn } = require('child_process')
const EventEmitter = require('events')
const Namespaces = require('./namespaces.js')

module.exports = Repos

function Repos(opts) {
  if (!(this instanceof Repos)) return new Repos(opts)
  EventEmitter.call(this)
  this._repodir = opts.repodir
  this._fs = opts.fs
  this._repos = new Map
  this._pathToId = new Map
  this._namespaces = new Namespaces({ data: opts.namespaces })
  if (Array.isArray(opts.repos)) {
    opts.repos.forEach(r => {
      this._addRepo(r)
    })
  }
}
Repos.prototype = Object.create(EventEmitter.prototype)

Repos.prototype._addRepo = function (r) {
  this._repos.set(r.id, r)
  if (Array.isArray(r.paths)) {
    r.paths.forEach(p => this._pathToId.set(normPath(p), r.id))
  }
}

Repos.prototype.list = function () {
  return Array.from(this._repos.values())
}

Repos.prototype.listAuthorized = function (ids) {
  let list = []
  for (let repo of this._repos.values()) {
    let p = repo.paths[0]
    let ok = this.isAuthorized('list', p, ids)
      || this.isAuthorized('remove', p, ids)
      || this.isAuthorized('create', p, ids)
    if (ok) list.push(repo)
  }
  return list
}

Repos.prototype.get = function (rid) {
  return this._repos.get(rid) ?? null
}

Repos.prototype.getFilePath = function (rid) {
  return path.join(this._repodir, rid)
}

Repos.prototype.match = function (rurl) {
  let rparts = rurl.replace(/\?.*/,'').replace(/^\//,'').split(/\/+/)
  for (let i = rparts.length; i >= 0; i--) {
    let p = rparts.slice(0,i).join('/') || '/'
    let id = this._pathToId.get(p)
    if (id === undefined) continue
    let repo = this._repos.get(id)
    if (repo !== null) {
      return { repo, outerPath: p, innerPath: rurl.slice(p.length+1) }
    }
  }
  return null
}

Repos.prototype.create = function (opts, cb) {
  let rid = randomBytes(6).toString('hex')
  let repo = {
    id: rid,
    paths: (opts.paths ?? []).map(p => p === '/' ? '/' : p.replace(/^\//,'')),
    list: opts.list ?? [],
    push: opts.push ?? [],
    pull: opts.pull ?? [],
    name: opts.name,
    description: opts.description,
    tags: opts.tags ?? [],
  }

  let rdir = path.join(this._repodir, rid)
  this._fs.mkdir(rdir, { recursive: true }, err => {
    if (err) return cb(err)
    let ps = spawn('git', ['init','--bare',rdir])
    ps.once('exit', code => {
      if (code !== 0) {
        return cb(new Error(`"git init" failed with exit code ${code}`))
      }
      this._addRepo(repo)
      this.emit('create', repo)
      cb(null, repo)
      //this._updateServerInfo(rdir, repo, cb)
    })
    ps.stdout.resume()
    ps.stderr.resume()
  })
}

Repos.prototype.update = function (rid, repo, cb) {
  let prev = this._repos.get(rid)
  if (prev && Array.isArray(prev.paths)) {
    prev.paths.forEach(p => this._pathToId.delete(p))
  }
  if (repo.id === undefined) {
    repo = Object.assign({}, repo, { id: rid })
  }
  if (repo.id !== undefined && rid !== repo.id) {
    this._repos.delete(rid)
  }
  repo = Object.assign({}, prev, repo)
  Object.keys(repo).forEach(key => {
    if (repo[key] === undefined) delete repo[key]
  })
  this._addRepo(repo)
  this.emit('update', rid, repo)
  nextTick(cb, null)
}

Repos.prototype.remove = function (rid, cb) {
  let repo = this._repos.get(rid)
  if (repo && Array.isArray(repo.paths)) {
    repo.paths.forEach(p => this._pathToId.delete(p))
  }
  this._repos.delete(rid)
  this.emit('remove', repo)
  nextTick(cb, null, repo)
}

/*
Repos.prototype._updateServerInfo = function (rdir, repo, cb) {
  let ps = spawn('git', ['update-server-info',rdir], { cwd: rdir })
  ps.once('exit', code => {
    if (code !== 0) {
      return cb(new Error(`"update-server-info" failed with exit code ${code}`))
    }
    this._addRepo(repo)
    cb(null, repo)
  })
  ps.stdout.resume()
  ps.stderr.resume()
}
*/

Repos.prototype.getIdFromPath = function (rpath) {
  return this._pathToId.get(normPath(rpath)) ?? null
}

Repos.prototype.matchPath = function (rpath, cb) {
}

Repos.prototype.getFromPath = function (rpath, cb) {
  let id = this.getIdFromPath(rpath)
  return this._repos.get(id) ?? null
}

Repos.prototype.isRepoAuthorized = function (action, repo, ids) {
  if (!repo) return false
  if (action === 'clone' || action === 'pull') {
    return authCheck(repo.pull ?? [], ids)
  } else if (action === 'push') {
    return authCheck(repo.push ?? [], ids)
  } else if (action === 'list') {
    return authCheck(repo.list ?? [], ids)
  }
  return false
}

Repos.prototype.isAuthorized = function (action, rpath, ids) {
  if (action === 'create' || action === 'remove' || action === 'rename') {
    return this._namespaces.isAuthorized(action, rpath, ids)
  }
  let repo = this.getFromPath(rpath)
  if (!repo) return false
  if (action === 'clone' || action === 'pull') {
    return authCheck(repo.pull ?? [], ids)
  } else if (action === 'push') {
    return authCheck(repo.push ?? [], ids)
  } else if (action === 'list') {
    return authCheck(repo.list ?? [], ids)
  } else {
    return false
  }
}

function authCheck(allowList, ids) {
  if (allowList.includes('*')) return true
  return ids.some(id => allowList.includes(id))
}

function normPath(x) {
  return x === '/' ? '/' : x.replace(/^\//,'')
}